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 01/12] 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 From b10ea2f41f75a6f16a12fe06533a6b910b70b500 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Brey?= Date: Tue, 24 May 2022 14:31:12 +0200 Subject: [PATCH 02/12] Add unit tests for MessageSearchHelper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Álvaro Brey --- .../controllers/util/MessageSearchHelper.kt | 8 +- .../util/MessageSearchHelperTest.kt | 146 ++++++++++++++++++ .../test/fakes/FakeUnifiedSearchRepository.kt | 47 ++++++ 3 files changed, 194 insertions(+), 7 deletions(-) create mode 100644 app/src/test/java/com/nextcloud/talk/controllers/util/MessageSearchHelperTest.kt create mode 100644 app/src/test/java/com/nextcloud/talk/test/fakes/FakeUnifiedSearchRepository.kt 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 index 1432b02ac..8a82398ec 100644 --- a/app/src/main/java/com/nextcloud/talk/controllers/util/MessageSearchHelper.kt +++ b/app/src/main/java/com/nextcloud/talk/controllers/util/MessageSearchHelper.kt @@ -41,6 +41,7 @@ class MessageSearchHelper( private var previousResults: List = emptyList() fun startMessageSearch(search: String): Observable { + resetCachedData() return doSearch(search) } @@ -56,7 +57,6 @@ class MessageSearchHelper( } private fun doSearch(search: String, cursor: Int = 0): Observable { - resetResultsIfNeeded(search) disposeIfPossible() return unifiedSearchRepository.searchMessages(user, search, cursor) .map { results -> @@ -76,12 +76,6 @@ class MessageSearchHelper( .doOnComplete(this::disposeIfPossible) } - private fun resetResultsIfNeeded(search: String) { - if (search != previousSearch) { - resetCachedData() - } - } - private fun resetCachedData() { previousSearch = null previousCursor = 0 diff --git a/app/src/test/java/com/nextcloud/talk/controllers/util/MessageSearchHelperTest.kt b/app/src/test/java/com/nextcloud/talk/controllers/util/MessageSearchHelperTest.kt new file mode 100644 index 000000000..6099a8e10 --- /dev/null +++ b/app/src/test/java/com/nextcloud/talk/controllers/util/MessageSearchHelperTest.kt @@ -0,0 +1,146 @@ +/* + * 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 com.nextcloud.talk.models.database.UserEntity +import com.nextcloud.talk.models.domain.SearchMessageEntry +import com.nextcloud.talk.repositories.unifiedsearch.UnifiedSearchRepository +import com.nextcloud.talk.test.fakes.FakeUnifiedSearchRepository +import io.reactivex.Observable +import org.junit.Assert +import org.junit.Before +import org.junit.Test +import org.mockito.Mock +import org.mockito.MockitoAnnotations + +class MessageSearchHelperTest { + + @Mock + lateinit var userEntity: UserEntity + + val repository = FakeUnifiedSearchRepository() + + @Suppress("LongParameterList") + private fun createMessageEntry( + searchTerm: String = "foo", + thumbnailURL: String = "foo", + title: String = "foo", + messageExcerpt: String = "foo", + conversationToken: String = "foo", + messageId: String? = "foo" + ) = SearchMessageEntry(searchTerm, thumbnailURL, title, messageExcerpt, conversationToken, messageId) + + @Before + fun setUp() { + MockitoAnnotations.openMocks(this) + } + + @Test + fun emptySearch() { + repository.response = UnifiedSearchRepository.UnifiedSearchResults(0, false, emptyList()) + + val sut = MessageSearchHelper(userEntity, repository) + + val testObserver = sut.startMessageSearch("foo").test() + testObserver.assertComplete() + testObserver.assertValueCount(1) + val expected = MessageSearchHelper.MessageSearchResults(emptyList(), false) + testObserver.assertValue(expected) + } + + @Test + fun nonEmptySearch_withMoreResults() { + val entries = (1..5).map { createMessageEntry() } + repository.response = UnifiedSearchRepository.UnifiedSearchResults(5, true, entries) + + val sut = MessageSearchHelper(userEntity, repository) + + val observable = sut.startMessageSearch("foo") + val expected = MessageSearchHelper.MessageSearchResults(entries, true) + testCall(observable, expected) + } + + @Test + fun nonEmptySearch_withNoMoreResults() { + val entries = (1..2).map { createMessageEntry() } + repository.response = UnifiedSearchRepository.UnifiedSearchResults(2, false, entries) + + val sut = MessageSearchHelper(userEntity, repository) + + val observable = sut.startMessageSearch("foo") + val expected = MessageSearchHelper.MessageSearchResults(entries, false) + testCall(observable, expected) + } + + @Test + fun nonEmptySearch_consecutiveSearches_sameResult() { + val entries = (1..2).map { createMessageEntry() } + repository.response = UnifiedSearchRepository.UnifiedSearchResults(2, false, entries) + + val sut = MessageSearchHelper(userEntity, repository) + + repeat(5) { + val observable = sut.startMessageSearch("foo") + val expected = MessageSearchHelper.MessageSearchResults(entries, false) + testCall(observable, expected) + } + } + + @Test + fun loadMore_noPreviousResults() { + val sut = MessageSearchHelper(userEntity, repository) + Assert.assertEquals(null, sut.loadMore()) + } + + @Test + fun loadMore_previousResults_sameSearch() { + val sut = MessageSearchHelper(userEntity, repository) + + val firstPageEntries = (1..5).map { createMessageEntry() } + repository.response = UnifiedSearchRepository.UnifiedSearchResults(5, true, firstPageEntries) + + val firstPageObservable = sut.startMessageSearch("foo") + Assert.assertEquals(0, repository.lastRequestedCursor) + val firstPageExpected = MessageSearchHelper.MessageSearchResults(firstPageEntries, true) + testCall(firstPageObservable, firstPageExpected) + + val secondPageEntries = (1..5).map { createMessageEntry(title = "bar") } + repository.response = UnifiedSearchRepository.UnifiedSearchResults(10, false, secondPageEntries) + + val secondPageObservable = sut.loadMore() + Assert.assertEquals(5, repository.lastRequestedCursor) + Assert.assertNotNull(secondPageObservable) + val secondPageExpected = MessageSearchHelper.MessageSearchResults(firstPageEntries + secondPageEntries, false) + testCall(secondPageObservable!!, secondPageExpected) + } + + private fun testCall( + searchCall: Observable, + expectedResult: MessageSearchHelper.MessageSearchResults + ) { + val testObserver = searchCall.test() + testObserver.assertComplete() + testObserver.assertValueCount(1) + testObserver.assertValue(expectedResult) + testObserver.dispose() + } +} diff --git a/app/src/test/java/com/nextcloud/talk/test/fakes/FakeUnifiedSearchRepository.kt b/app/src/test/java/com/nextcloud/talk/test/fakes/FakeUnifiedSearchRepository.kt new file mode 100644 index 000000000..cf2827934 --- /dev/null +++ b/app/src/test/java/com/nextcloud/talk/test/fakes/FakeUnifiedSearchRepository.kt @@ -0,0 +1,47 @@ +/* + * 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.test.fakes + +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 + +class FakeUnifiedSearchRepository : UnifiedSearchRepository { + + lateinit var response: UnifiedSearchRepository.UnifiedSearchResults + var lastRequestedCursor = -1 + + override fun searchMessages( + userEntity: UserEntity, + searchTerm: String, + cursor: Int, + limit: Int + ): Observable> { + lastRequestedCursor = cursor + return Observable.just(response) + } + + override fun searchInRoom(text: String, roomId: String): Observable> { + TODO("Not yet implemented") + } +} From d1d61e87a96f3c0044580d336ca9b86b848da9c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Brey?= Date: Tue, 24 May 2022 14:48:55 +0200 Subject: [PATCH 03/12] Use rxjava to debounce search instead of custom debouncer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Álvaro Brey --- .../java/com/nextcloud/talk/api/NcApi.java | 2 - .../ConversationsListController.java | 37 +++++++------- .../com/nextcloud/talk/utils/Debouncer.kt | 34 ------------- .../talk/utils/rx/SearchViewObservable.kt | 48 +++++++++++++++++++ 4 files changed, 67 insertions(+), 54 deletions(-) delete mode 100644 app/src/main/java/com/nextcloud/talk/utils/Debouncer.kt create mode 100644 app/src/main/java/com/nextcloud/talk/utils/rx/SearchViewObservable.kt 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 35742aeb5..f2d96d83d 100644 --- a/app/src/main/java/com/nextcloud/talk/api/NcApi.java +++ b/app/src/main/java/com/nextcloud/talk/api/NcApi.java @@ -66,7 +66,6 @@ import retrofit2.http.FieldMap; import retrofit2.http.FormUrlEncoded; import retrofit2.http.GET; import retrofit2.http.Header; -import retrofit2.http.Headers; import retrofit2.http.Multipart; import retrofit2.http.POST; import retrofit2.http.PUT; @@ -521,7 +520,6 @@ public interface NcApi { @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, 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 a84ec2a25..ec59e4833 100644 --- a/app/src/main/java/com/nextcloud/talk/controllers/ConversationsListController.java +++ b/app/src/main/java/com/nextcloud/talk/controllers/ConversationsListController.java @@ -95,12 +95,12 @@ 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; import com.nextcloud.talk.utils.database.user.UserUtils; import com.nextcloud.talk.utils.preferences.AppPreferences; +import com.nextcloud.talk.utils.rx.SearchViewObservable; import com.webianks.library.PopupBubble; import com.yarolegovich.lovelydialog.LovelySaveStateHandler; import com.yarolegovich.lovelydialog.LovelyStandardDialog; @@ -117,6 +117,7 @@ import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Objects; +import java.util.concurrent.TimeUnit; import javax.inject.Inject; @@ -147,8 +148,7 @@ import io.reactivex.schedulers.Schedulers; import retrofit2.HttpException; @AutoInjector(NextcloudTalkApplication.class) -public class ConversationsListController extends BaseController implements SearchView.OnQueryTextListener, - FlexibleAdapter.OnItemClickListener, FlexibleAdapter.OnItemLongClickListener, ConversationMenuInterface { +public class ConversationsListController extends BaseController implements FlexibleAdapter.OnItemClickListener, FlexibleAdapter.OnItemLongClickListener, ConversationMenuInterface { public static final String TAG = "ConvListController"; public static final int ID_DELETE_CONVERSATION_DIALOG = 0; @@ -236,9 +236,8 @@ public class ConversationsListController extends BaseController implements Searc private HashMap userStatuses = new HashMap<>(); - private Debouncer searchDebouncer = new Debouncer(SEARCH_DEBOUNCE_INTERVAL_MS); - private MessageSearchHelper searchHelper; + private Disposable searchViewDisposable; public ConversationsListController(Bundle bundle) { super(); @@ -361,7 +360,18 @@ public class ConversationsListController extends BaseController implements Searc if (searchManager != null) { searchView.setSearchableInfo(searchManager.getSearchableInfo(getActivity().getComponentName())); } - searchView.setOnQueryTextListener(this); + searchViewDisposable = SearchViewObservable.observeSearchView(searchView) + .debounce(query -> { + if (TextUtils.isEmpty(query)) { + return Observable.empty(); + } else { + return Observable.timer(SEARCH_DEBOUNCE_INTERVAL_MS, TimeUnit.MILLISECONDS); + } + }) + .distinctUntilChanged() + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(this::onQueryTextChange); } } } @@ -869,21 +879,17 @@ public class ConversationsListController extends BaseController implements Searc public void onDestroy() { super.onDestroy(); dispose(null); + searchViewDisposable.dispose(); } - @Override - public boolean onQueryTextChange(final String newText) { + public void 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); - }); + performFilterAndSearch(newText); } - return true; } private void performFilterAndSearch(String filter) { @@ -939,11 +945,6 @@ public class ConversationsListController extends BaseController implements Searc } - @Override - public boolean onQueryTextSubmit(String query) { - return onQueryTextChange(query); - } - @Override protected String getTitle() { return getResources().getString(R.string.nc_app_product_name); diff --git a/app/src/main/java/com/nextcloud/talk/utils/Debouncer.kt b/app/src/main/java/com/nextcloud/talk/utils/Debouncer.kt deleted file mode 100644 index b0ef01bb5..000000000 --- a/app/src/main/java/com/nextcloud/talk/utils/Debouncer.kt +++ /dev/null @@ -1,34 +0,0 @@ -/* - * 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/rx/SearchViewObservable.kt b/app/src/main/java/com/nextcloud/talk/utils/rx/SearchViewObservable.kt new file mode 100644 index 000000000..969c0cc80 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/utils/rx/SearchViewObservable.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.utils.rx + +import androidx.appcompat.widget.SearchView +import io.reactivex.Observable +import io.reactivex.subjects.PublishSubject + +class SearchViewObservable { + + companion object { + @JvmStatic + fun observeSearchView(searchView: SearchView): Observable { + val subject: PublishSubject = PublishSubject.create() + searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener { + override fun onQueryTextSubmit(query: String): Boolean { + subject.onComplete() + return true + } + + override fun onQueryTextChange(newText: String): Boolean { + subject.onNext(newText) + return true + } + }) + return subject + } + } +} From b5d8f6ee951fea543024749b3de988c4df70684e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Brey?= Date: Wed, 25 May 2022 16:29:17 +0200 Subject: [PATCH 04/12] Implement search in specific chat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Álvaro Brey --- app/src/main/AndroidManifest.xml | 5 + .../talk/adapters/items/MessageResultItem.kt | 2 +- .../talk/controllers/ChatController.kt | 31 ++- .../ConversationsListController.java | 2 +- .../talk/dagger/modules/ViewModelModule.kt | 6 + .../messagesearch/MessageSearchActivity.kt | 238 ++++++++++++++++++ .../MessageSearchHelper.kt | 30 ++- .../messagesearch/MessageSearchViewModel.kt | 114 +++++++++ .../unifiedsearch/UnifiedSearchRepository.kt | 8 +- .../UnifiedSearchRepositoryImpl.kt | 20 +- .../res/layout/activity_message_search.xml | 63 +++++ app/src/main/res/layout/empty_list.xml | 3 + app/src/main/res/menu/menu_conversation.xml | 11 +- app/src/main/res/menu/menu_search.xml | 30 +++ app/src/main/res/values/strings.xml | 17 +- .../MessageSearchHelperTest.kt | 2 +- .../test/fakes/FakeUnifiedSearchRepository.kt | 11 +- 17 files changed, 565 insertions(+), 28 deletions(-) create mode 100644 app/src/main/java/com/nextcloud/talk/messagesearch/MessageSearchActivity.kt rename app/src/main/java/com/nextcloud/talk/{controllers/util => messagesearch}/MessageSearchHelper.kt (77%) create mode 100644 app/src/main/java/com/nextcloud/talk/messagesearch/MessageSearchViewModel.kt create mode 100644 app/src/main/res/layout/activity_message_search.xml create mode 100644 app/src/main/res/menu/menu_search.xml rename app/src/test/java/com/nextcloud/talk/{controllers/util => messagesearch}/MessageSearchHelperTest.kt (99%) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 41ae4ce7f..2aa763474 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -172,6 +172,11 @@ android:name=".shareditems.activities.SharedItemsActivity" android:theme="@style/AppTheme"/> + + 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 index 725ed5e1c..bc7e25d23 100644 --- a/app/src/main/java/com/nextcloud/talk/adapters/items/MessageResultItem.kt +++ b/app/src/main/java/com/nextcloud/talk/adapters/items/MessageResultItem.kt @@ -42,7 +42,7 @@ data class MessageResultItem constructor( private val context: Context, private val currentUser: UserEntity, val messageEntry: SearchMessageEntry, - private val showHeader: Boolean + private val showHeader: Boolean = false ) : AbstractFlexibleItem(), IFilterable, diff --git a/app/src/main/java/com/nextcloud/talk/controllers/ChatController.kt b/app/src/main/java/com/nextcloud/talk/controllers/ChatController.kt index 5fc0aa2a2..b0c561588 100644 --- a/app/src/main/java/com/nextcloud/talk/controllers/ChatController.kt +++ b/app/src/main/java/com/nextcloud/talk/controllers/ChatController.kt @@ -130,6 +130,7 @@ import com.nextcloud.talk.events.UserMentionClickEvent import com.nextcloud.talk.events.WebSocketCommunicationEvent import com.nextcloud.talk.jobs.DownloadFileToCacheWorker import com.nextcloud.talk.jobs.UploadAndShareFilesWorker +import com.nextcloud.talk.messagesearch.MessageSearchActivity import com.nextcloud.talk.models.database.CapabilitiesUtil import com.nextcloud.talk.models.database.UserEntity import com.nextcloud.talk.models.json.chat.ChatMessage @@ -1346,6 +1347,7 @@ class ChatController(args: Bundle) : override fun onActivityResult(requestCode: Int, resultCode: Int, intent: Intent?) { if (resultCode != RESULT_OK) { + // TODO for message search, CANCELED is fine Log.e(TAG, "resultCode for received intent was != ok") return } @@ -1452,6 +1454,8 @@ class ChatController(args: Bundle) : Log.e(javaClass.simpleName, "Something went wrong when trying to upload file", e) } } + } else if (requestCode == REQUEST_CODE_MESSAGE_SEARCH) { + TODO() } } @@ -2469,28 +2473,32 @@ class ChatController(args: Bundle) : } override fun onOptionsItemSelected(item: MenuItem): Boolean { - when (item.itemId) { + return when (item.itemId) { android.R.id.home -> { (activity as MainActivity).resetConversationsList() - return true + true } R.id.conversation_video_call -> { startACall(false, false) - return true + true } R.id.conversation_voice_call -> { startACall(true, false) - return true + true } R.id.conversation_info -> { showConversationInfoScreen() - return true + true } R.id.shared_items -> { showSharedItems() - return true + true } - else -> return super.onOptionsItemSelected(item) + R.id.conversation_search -> { + startMessageSearch() + true + } + else -> super.onOptionsItemSelected(item) } } @@ -2502,6 +2510,14 @@ class ChatController(args: Bundle) : activity!!.startActivity(intent) } + private fun startMessageSearch() { + val intent = Intent(activity, MessageSearchActivity::class.java) + intent.putExtra(KEY_CONVERSATION_NAME, currentConversation?.displayName) + intent.putExtra(KEY_ROOM_TOKEN, roomToken) + intent.putExtra(KEY_USER_ENTITY, conversationUser as Parcelable) + activity!!.startActivityForResult(intent, REQUEST_CODE_MESSAGE_SEARCH) + } + private fun handleSystemMessages(chatMessageList: List): List { val chatMessageMap = chatMessageList.map { it.id to it }.toMap().toMutableMap() val chatMessageIterator = chatMessageMap.iterator() @@ -3087,6 +3103,7 @@ class ChatController(args: Bundle) : private const val AGE_THREHOLD_FOR_DELETE_MESSAGE: Int = 21600000 // (6 hours in millis = 6 * 3600 * 1000) private const val REQUEST_CODE_CHOOSE_FILE: Int = 555 private const val REQUEST_CODE_SELECT_CONTACT: Int = 666 + private const val REQUEST_CODE_MESSAGE_SEARCH: Int = 777 private const val REQUEST_RECORD_AUDIO_PERMISSION = 222 private const val REQUEST_READ_CONTACT_PERMISSION = 234 private const val REQUEST_CAMERA_PERMISSION = 223 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 ec59e4833..c76341bf6 100644 --- a/app/src/main/java/com/nextcloud/talk/controllers/ConversationsListController.java +++ b/app/src/main/java/com/nextcloud/talk/controllers/ConversationsListController.java @@ -74,7 +74,7 @@ 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.messagesearch.MessageSearchHelper; import com.nextcloud.talk.events.ConversationsListFetchDataEvent; import com.nextcloud.talk.events.EventStatus; import com.nextcloud.talk.interfaces.ConversationMenuInterface; diff --git a/app/src/main/java/com/nextcloud/talk/dagger/modules/ViewModelModule.kt b/app/src/main/java/com/nextcloud/talk/dagger/modules/ViewModelModule.kt index d684db1b2..b0f7170d8 100644 --- a/app/src/main/java/com/nextcloud/talk/dagger/modules/ViewModelModule.kt +++ b/app/src/main/java/com/nextcloud/talk/dagger/modules/ViewModelModule.kt @@ -23,6 +23,7 @@ package com.nextcloud.talk.dagger.modules import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider +import com.nextcloud.talk.messagesearch.MessageSearchViewModel import com.nextcloud.talk.shareditems.viewmodels.SharedItemsViewModel import dagger.Binds import dagger.MapKey @@ -53,4 +54,9 @@ abstract class ViewModelModule { @IntoMap @ViewModelKey(SharedItemsViewModel::class) abstract fun sharedItemsViewModel(viewModel: SharedItemsViewModel): ViewModel + + @Binds + @IntoMap + @ViewModelKey(MessageSearchViewModel::class) + abstract fun messageSearchViewModel(viewModel: MessageSearchViewModel): ViewModel } diff --git a/app/src/main/java/com/nextcloud/talk/messagesearch/MessageSearchActivity.kt b/app/src/main/java/com/nextcloud/talk/messagesearch/MessageSearchActivity.kt new file mode 100644 index 000000000..5c84c292a --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/messagesearch/MessageSearchActivity.kt @@ -0,0 +1,238 @@ +/* + * 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.messagesearch + +import android.app.Activity +import android.os.Bundle +import android.text.TextUtils +import android.view.Menu +import android.view.MenuItem +import android.view.View +import android.widget.Toast +import androidx.appcompat.widget.SearchView +import androidx.core.content.res.ResourcesCompat +import androidx.lifecycle.ViewModelProvider +import autodagger.AutoInjector +import com.nextcloud.talk.R +import com.nextcloud.talk.activities.BaseActivity +import com.nextcloud.talk.adapters.items.LoadMoreResultsItem +import com.nextcloud.talk.adapters.items.MessageResultItem +import com.nextcloud.talk.application.NextcloudTalkApplication +import com.nextcloud.talk.controllers.ConversationsListController +import com.nextcloud.talk.databinding.ActivityMessageSearchBinding +import com.nextcloud.talk.models.database.UserEntity +import com.nextcloud.talk.utils.DisplayUtils +import com.nextcloud.talk.utils.bundle.BundleKeys +import com.nextcloud.talk.utils.rx.SearchViewObservable.Companion.observeSearchView +import eu.davidea.flexibleadapter.FlexibleAdapter +import eu.davidea.flexibleadapter.items.AbstractFlexibleItem +import eu.davidea.viewholders.FlexibleViewHolder +import io.reactivex.Observable +import io.reactivex.android.schedulers.AndroidSchedulers +import io.reactivex.disposables.Disposable +import io.reactivex.schedulers.Schedulers +import java.util.concurrent.TimeUnit +import javax.inject.Inject + +@AutoInjector(NextcloudTalkApplication::class) +class MessageSearchActivity : BaseActivity() { + + @Inject + lateinit var viewModelFactory: ViewModelProvider.Factory + + private lateinit var binding: ActivityMessageSearchBinding + private lateinit var searchView: SearchView + + private lateinit var user: UserEntity + + private lateinit var viewModel: MessageSearchViewModel + + private var searchViewDisposable: Disposable? = null + private var adapter: FlexibleAdapter>? = null + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this) + + binding = ActivityMessageSearchBinding.inflate(layoutInflater) + setupActionBar() + setupSystemColors() + setContentView(binding.root) + + viewModel = ViewModelProvider(this, viewModelFactory)[MessageSearchViewModel::class.java] + user = intent.getParcelableExtra(BundleKeys.KEY_USER_ENTITY)!! + val roomToken = intent.getStringExtra(BundleKeys.KEY_ROOM_TOKEN)!! + viewModel.initialize(user, roomToken) + setupStateObserver() + } + + private fun setupActionBar() { + setSupportActionBar(binding.messageSearchToolbar) + supportActionBar?.setDisplayHomeAsUpEnabled(true) + val conversationName = intent.getStringExtra(BundleKeys.KEY_CONVERSATION_NAME) + supportActionBar?.title = conversationName + } + + private fun setupSystemColors() { + DisplayUtils.applyColorToStatusBar( + this, + ResourcesCompat.getColor( + resources, R.color.appbar, null + ) + ) + DisplayUtils.applyColorToNavigationBar( + this.window, + ResourcesCompat.getColor(resources, R.color.bg_default, null) + ) + } + + private fun setupStateObserver() { + viewModel.state.observe(this) { state -> + when (state) { + MessageSearchViewModel.EmptyState -> showEmpty() + MessageSearchViewModel.InitialState -> showInitial() + is MessageSearchViewModel.LoadedState -> showLoaded(state) + MessageSearchViewModel.LoadingState -> showLoading() + MessageSearchViewModel.ErrorState -> showError() + } + } + } + + private fun showError() { + Toast.makeText(this, "Error while searching", Toast.LENGTH_SHORT).show() + } + + private fun showLoading() { + // TODO + Toast.makeText(this, "LOADING", Toast.LENGTH_LONG).show() + } + + private fun showLoaded(state: MessageSearchViewModel.LoadedState) { + binding.emptyContainer.emptyListView.visibility = View.GONE + binding.messageSearchRecycler.visibility = View.VISIBLE + setAdapterItems(state) + } + + private fun setAdapterItems(state: MessageSearchViewModel.LoadedState) { + val loadMoreItems = if (state.hasMore) { + listOf(LoadMoreResultsItem) + } else { + emptyList() + } + val newItems = + state.results.map { MessageResultItem(this, user, it) } + loadMoreItems + + if (adapter != null) { + adapter!!.updateDataSet(newItems) + } else { + createAdapter(newItems) + } + } + + private fun createAdapter(items: List>) { + adapter = FlexibleAdapter(items) + binding.messageSearchRecycler.adapter = adapter + adapter!!.addListener(object : FlexibleAdapter.OnItemClickListener { + override fun onItemClick(view: View?, position: Int): Boolean { + val item = adapter!!.getItem(position) + if (item?.itemViewType == LoadMoreResultsItem.VIEW_TYPE) { + viewModel.loadMore() + } + return false + } + }) + } + + private fun showInitial() { + binding.messageSearchRecycler.visibility = View.GONE + binding.emptyContainer.emptyListViewHeadline.text = "Start typing to search..." + binding.emptyContainer.emptyListView.visibility = View.VISIBLE + } + + private fun showEmpty() { + binding.messageSearchRecycler.visibility = View.GONE + binding.emptyContainer.emptyListViewHeadline.text = "No search results" + binding.emptyContainer.emptyListView.visibility = View.VISIBLE + } + + override fun onCreateOptionsMenu(menu: Menu?): Boolean { + menuInflater.inflate(R.menu.menu_search, menu) + return true + } + + override fun onPrepareOptionsMenu(menu: Menu?): Boolean { + val menuItem = menu!!.findItem(R.id.action_search) + searchView = menuItem.actionView as SearchView + setupSearchView() + menuItem.setOnActionExpandListener(object : MenuItem.OnActionExpandListener { + override fun onMenuItemActionExpand(item: MenuItem?): Boolean { + searchView.requestFocus() + return true + } + + override fun onMenuItemActionCollapse(item: MenuItem?): Boolean { + onBackPressed() + return false + } + }) + menuItem.expandActionView() + return true + } + + private fun setupSearchView() { + searchView.queryHint = getString(R.string.nc_search_hint) + searchViewDisposable = observeSearchView(searchView) + .debounce { query -> + when { + TextUtils.isEmpty(query) -> Observable.empty() + else -> Observable.timer( + ConversationsListController.SEARCH_DEBOUNCE_INTERVAL_MS.toLong(), + TimeUnit.MILLISECONDS + ) + } + } + .distinctUntilChanged() + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe { newText -> viewModel.onQueryTextChange(newText) } + } + + override fun onBackPressed() { + setResult(Activity.RESULT_CANCELED) + finish() + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + return when (item.itemId) { + android.R.id.home -> { + onBackPressed() + true + } + else -> super.onOptionsItemSelected(item) + } + } + + override fun onDestroy() { + super.onDestroy() + searchViewDisposable?.dispose() + } +} diff --git a/app/src/main/java/com/nextcloud/talk/controllers/util/MessageSearchHelper.kt b/app/src/main/java/com/nextcloud/talk/messagesearch/MessageSearchHelper.kt similarity index 77% rename from app/src/main/java/com/nextcloud/talk/controllers/util/MessageSearchHelper.kt rename to app/src/main/java/com/nextcloud/talk/messagesearch/MessageSearchHelper.kt index 8a82398ec..0350a9f73 100644 --- a/app/src/main/java/com/nextcloud/talk/controllers/util/MessageSearchHelper.kt +++ b/app/src/main/java/com/nextcloud/talk/messagesearch/MessageSearchHelper.kt @@ -19,7 +19,7 @@ * along with this program. If not, see . */ -package com.nextcloud.talk.controllers.util +package com.nextcloud.talk.messagesearch import android.util.Log import com.nextcloud.talk.models.database.UserEntity @@ -28,9 +28,10 @@ import com.nextcloud.talk.repositories.unifiedsearch.UnifiedSearchRepository import io.reactivex.Observable import io.reactivex.disposables.Disposable -class MessageSearchHelper( +class MessageSearchHelper @JvmOverloads constructor( private val user: UserEntity, private val unifiedSearchRepository: UnifiedSearchRepository, + private val fromRoom: String? = null ) { data class MessageSearchResults(val messages: List, val hasMore: Boolean) @@ -58,7 +59,7 @@ class MessageSearchHelper( private fun doSearch(search: String, cursor: Int = 0): Observable { disposeIfPossible() - return unifiedSearchRepository.searchMessages(user, search, cursor) + return searchCall(search, cursor) .map { results -> previousSearch = search previousCursor = results.cursor @@ -76,6 +77,29 @@ class MessageSearchHelper( .doOnComplete(this::disposeIfPossible) } + private fun searchCall( + search: String, + cursor: Int + ): Observable> { + return when { + fromRoom != null -> { + unifiedSearchRepository.searchInRoom( + userEntity = user, + roomToken = fromRoom, + searchTerm = search, + cursor = cursor + ) + } + else -> { + unifiedSearchRepository.searchMessages( + userEntity = user, + searchTerm = search, + cursor = cursor + ) + } + } + } + private fun resetCachedData() { previousSearch = null previousCursor = 0 diff --git a/app/src/main/java/com/nextcloud/talk/messagesearch/MessageSearchViewModel.kt b/app/src/main/java/com/nextcloud/talk/messagesearch/MessageSearchViewModel.kt new file mode 100644 index 000000000..a7b0acd9e --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/messagesearch/MessageSearchViewModel.kt @@ -0,0 +1,114 @@ +/* + * 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.messagesearch + +import android.annotation.SuppressLint +import android.util.Log +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import com.nextcloud.talk.models.database.UserEntity +import com.nextcloud.talk.models.domain.SearchMessageEntry +import com.nextcloud.talk.repositories.unifiedsearch.UnifiedSearchRepository +import io.reactivex.android.schedulers.AndroidSchedulers +import io.reactivex.disposables.Disposable +import io.reactivex.schedulers.Schedulers +import javax.inject.Inject + +/** + * Install PlantUML plugin to render this state diagram + * @startuml + * hide empty description + * [*] --> InitialState + * InitialState --> LoadingState + * LoadingState --> EmptyState + * LoadingState --> LoadedState + * LoadingState --> LoadingState + * LoadedState --> LoadingState + * EmptyState --> LoadingState + * LoadingState --> ErrorState + * ErrorState --> LoadingState + * @enduml + */ +class MessageSearchViewModel @Inject constructor(private val unifiedSearchRepository: UnifiedSearchRepository) : + ViewModel() { + + sealed class ViewState + object InitialState : ViewState() + object LoadingState : ViewState() + object EmptyState : ViewState() + object ErrorState : ViewState() + class LoadedState(val results: List, val hasMore: Boolean) : ViewState() + + private lateinit var messageSearchHelper: MessageSearchHelper + + private val _state: MutableLiveData = MutableLiveData(InitialState) + val state: LiveData + get() = _state + + private var searchDisposable: Disposable? = null + + fun initialize(user: UserEntity, roomToken: String) { + messageSearchHelper = MessageSearchHelper(user, unifiedSearchRepository, roomToken) + } + + @SuppressLint("CheckResult") // handled by helper + fun onQueryTextChange(newText: String) { + if (newText.length >= MIN_CHARS_FOR_SEARCH) { + _state.value = LoadingState + messageSearchHelper.cancelSearch() + messageSearchHelper.startMessageSearch(newText) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(this::onReceiveResults, this::onError) + } + } + + @SuppressLint("CheckResult") // handled by helper + fun loadMore() { + _state.value = LoadingState + messageSearchHelper.cancelSearch() + messageSearchHelper.loadMore() + ?.subscribeOn(Schedulers.io()) + ?.observeOn(AndroidSchedulers.mainThread()) + ?.subscribe(this::onReceiveResults) + } + + private fun onReceiveResults(results: MessageSearchHelper.MessageSearchResults) { + if (results.messages.isEmpty()) { + _state.value = EmptyState + } else { + _state.value = LoadedState(results.messages, results.hasMore) + } + } + + private fun onError(throwable: Throwable) { + Log.e(TAG, "onError:", throwable) + messageSearchHelper.cancelSearch() + _state.value = ErrorState + } + + companion object { + private val TAG = MessageSearchViewModel::class.simpleName + private const val MIN_CHARS_FOR_SEARCH = 2 + } +} 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 index 43a416395..4d91e1219 100644 --- a/app/src/main/java/com/nextcloud/talk/repositories/unifiedsearch/UnifiedSearchRepository.kt +++ b/app/src/main/java/com/nextcloud/talk/repositories/unifiedsearch/UnifiedSearchRepository.kt @@ -18,7 +18,13 @@ interface UnifiedSearchRepository { limit: Int = DEFAULT_PAGE_SIZE ): Observable> - fun searchInRoom(text: String, roomId: String): Observable> + fun searchInRoom( + userEntity: UserEntity, + roomToken: String, + searchTerm: String, + cursor: Int = 0, + limit: Int = DEFAULT_PAGE_SIZE + ): 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 index 430aca3d4..1f0d072c3 100644 --- a/app/src/main/java/com/nextcloud/talk/repositories/unifiedsearch/UnifiedSearchRepositoryImpl.kt +++ b/app/src/main/java/com/nextcloud/talk/repositories/unifiedsearch/UnifiedSearchRepositoryImpl.kt @@ -48,10 +48,26 @@ class UnifiedSearchRepositoryImpl(private val api: NcApi) : UnifiedSearchReposit return apiObservable.map { mapToMessageResults(it.ocs?.data!!, searchTerm, limit) } } - override fun searchInRoom(text: String, roomId: String): Observable> { - TODO() + override fun searchInRoom( + userEntity: UserEntity, + roomToken: String, + searchTerm: String, + cursor: Int, + limit: Int + ): Observable> { + val apiObservable = api.performUnifiedSearch( + ApiUtils.getCredentials(userEntity.username, userEntity.token), + ApiUtils.getUrlForUnifiedSearch(userEntity.baseUrl, PROVIDER_TALK_MESSAGE_CURRENT), + searchTerm, + fromUrlForRoom(roomToken), + limit, + cursor + ) + return apiObservable.map { mapToMessageResults(it.ocs?.data!!, searchTerm, limit) } } + private fun fromUrlForRoom(roomToken: String) = "/call/$roomToken" + companion object { private const val PROVIDER_TALK_MESSAGE = "talk-message" private const val PROVIDER_TALK_MESSAGE_CURRENT = "talk-message-current" diff --git a/app/src/main/res/layout/activity_message_search.xml b/app/src/main/res/layout/activity_message_search.xml new file mode 100644 index 000000000..5c7ddf74c --- /dev/null +++ b/app/src/main/res/layout/activity_message_search.xml @@ -0,0 +1,63 @@ + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/empty_list.xml b/app/src/main/res/layout/empty_list.xml index 40ad3c2c7..f1cc5e8c5 100644 --- a/app/src/main/res/layout/empty_list.xml +++ b/app/src/main/res/layout/empty_list.xml @@ -20,6 +20,7 @@ License along with this program. If not, see . --> diff --git a/app/src/main/res/menu/menu_conversation.xml b/app/src/main/res/menu/menu_conversation.xml index 28fea422b..7b923aea6 100644 --- a/app/src/main/res/menu/menu_conversation.xml +++ b/app/src/main/res/menu/menu_conversation.xml @@ -35,15 +35,22 @@ android:title="@string/nc_conversation_menu_video_call" app:showAsAction="ifRoom" /> + + diff --git a/app/src/main/res/menu/menu_search.xml b/app/src/main/res/menu/menu_search.xml new file mode 100644 index 000000000..dfb61e115 --- /dev/null +++ b/app/src/main/res/menu/menu_search.xml @@ -0,0 +1,30 @@ + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 4beee18ec..cce6394d6 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -273,14 +273,14 @@ Do not disturb Away Invisible - - 😃 - 👍 - 👎 - ❤️ - 😯 - 😢 - More emojis + + 😃 + 👍 + 👎 + ❤️ + 😯 + 😢 + More emojis Don\'t clear Today 30 minutes @@ -525,5 +525,6 @@ Call without notification Messages Load more results + Search… diff --git a/app/src/test/java/com/nextcloud/talk/controllers/util/MessageSearchHelperTest.kt b/app/src/test/java/com/nextcloud/talk/messagesearch/MessageSearchHelperTest.kt similarity index 99% rename from app/src/test/java/com/nextcloud/talk/controllers/util/MessageSearchHelperTest.kt rename to app/src/test/java/com/nextcloud/talk/messagesearch/MessageSearchHelperTest.kt index 6099a8e10..4dd9a5560 100644 --- a/app/src/test/java/com/nextcloud/talk/controllers/util/MessageSearchHelperTest.kt +++ b/app/src/test/java/com/nextcloud/talk/messagesearch/MessageSearchHelperTest.kt @@ -19,7 +19,7 @@ * along with this program. If not, see . */ -package com.nextcloud.talk.controllers.util +package com.nextcloud.talk.messagesearch import com.nextcloud.talk.models.database.UserEntity import com.nextcloud.talk.models.domain.SearchMessageEntry diff --git a/app/src/test/java/com/nextcloud/talk/test/fakes/FakeUnifiedSearchRepository.kt b/app/src/test/java/com/nextcloud/talk/test/fakes/FakeUnifiedSearchRepository.kt index cf2827934..2a216e69e 100644 --- a/app/src/test/java/com/nextcloud/talk/test/fakes/FakeUnifiedSearchRepository.kt +++ b/app/src/test/java/com/nextcloud/talk/test/fakes/FakeUnifiedSearchRepository.kt @@ -41,7 +41,14 @@ class FakeUnifiedSearchRepository : UnifiedSearchRepository { return Observable.just(response) } - override fun searchInRoom(text: String, roomId: String): Observable> { - TODO("Not yet implemented") + override fun searchInRoom( + userEntity: UserEntity, + roomToken: String, + searchTerm: String, + cursor: Int, + limit: Int + ): Observable> { + lastRequestedCursor = cursor + return Observable.just(response) } } From dd55ab5741beec287328b9d54d660d8c5d4ee08e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Brey?= Date: Fri, 27 May 2022 17:17:07 +0200 Subject: [PATCH 05/12] Add ability to scroll to message selected in search results MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Álvaro Brey --- .../talk/controllers/ChatController.kt | 205 ++++++++++-------- .../ConversationsListController.java | 20 +- .../messagesearch/MessageSearchActivity.kt | 24 +- .../nextcloud/talk/utils/bundle/BundleKeys.kt | 1 + 4 files changed, 158 insertions(+), 92 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/controllers/ChatController.kt b/app/src/main/java/com/nextcloud/talk/controllers/ChatController.kt index b0c561588..dccabdd6c 100644 --- a/app/src/main/java/com/nextcloud/talk/controllers/ChatController.kt +++ b/app/src/main/java/com/nextcloud/talk/controllers/ChatController.kt @@ -1346,92 +1346,21 @@ class ChatController(args: Bundle) : } override fun onActivityResult(requestCode: Int, resultCode: Int, intent: Intent?) { - if (resultCode != RESULT_OK) { - // TODO for message search, CANCELED is fine + if (resultCode != RESULT_OK && (requestCode != REQUEST_CODE_MESSAGE_SEARCH)) { Log.e(TAG, "resultCode for received intent was != ok") return } - if (requestCode == REQUEST_CODE_CHOOSE_FILE) { - try { - checkNotNull(intent) - filesToUpload.clear() - intent.clipData?.let { - for (index in 0 until it.itemCount) { - filesToUpload.add(it.getItemAt(index).uri.toString()) - } - } ?: run { - checkNotNull(intent.data) - intent.data.let { - filesToUpload.add(intent.data.toString()) - } - } - require(filesToUpload.isNotEmpty()) - - val filenamesWithLinebreaks = StringBuilder("\n") - - for (file in filesToUpload) { - val filename = UriUtils.getFileName(Uri.parse(file), context) - filenamesWithLinebreaks.append(filename).append("\n") - } - - val confirmationQuestion = when (filesToUpload.size) { - 1 -> context?.resources?.getString(R.string.nc_upload_confirm_send_single)?.let { - String.format(it, title) - } - else -> context?.resources?.getString(R.string.nc_upload_confirm_send_multiple)?.let { - String.format(it, title) - } - } - - LovelyStandardDialog(activity) - .setPositiveButtonColorRes(R.color.nc_darkGreen) - .setTitle(confirmationQuestion) - .setMessage(filenamesWithLinebreaks.toString()) - .setPositiveButton(R.string.nc_yes) { v -> - if (UploadAndShareFilesWorker.isStoragePermissionGranted(context!!)) { - uploadFiles(filesToUpload, false) - } else { - UploadAndShareFilesWorker.requestStoragePermission(this) - } - } - .setNegativeButton(R.string.nc_no) { - // unused atm - } - .show() - } catch (e: IllegalStateException) { - Toast.makeText(context, context?.resources?.getString(R.string.nc_upload_failed), Toast.LENGTH_LONG) - .show() - Log.e(javaClass.simpleName, "Something went wrong when trying to upload file", e) - } catch (e: IllegalArgumentException) { - Toast.makeText(context, context?.resources?.getString(R.string.nc_upload_failed), Toast.LENGTH_LONG) - .show() - Log.e(javaClass.simpleName, "Something went wrong when trying to upload file", e) - } - } else if (requestCode == REQUEST_CODE_SELECT_CONTACT) { - val contactUri = intent?.data ?: return - val cursor: Cursor? = activity?.contentResolver!!.query(contactUri, null, null, null, null) - - if (cursor != null && cursor.moveToFirst()) { - val id = cursor.getString(cursor.getColumnIndexOrThrow(ContactsContract.Contacts._ID)) - val fileName = ContactUtils.getDisplayNameFromDeviceContact(context!!, id) + ".vcf" - val file = File(context?.cacheDir, fileName) - writeContactToVcfFile(cursor, file) - - val shareUri = FileProvider.getUriForFile( - activity!!, - BuildConfig.APPLICATION_ID, - File(file.absolutePath) - ) - uploadFiles(mutableListOf(shareUri.toString()), false) - } - cursor?.close() - } else if (requestCode == REQUEST_CODE_PICK_CAMERA) { - if (resultCode == RESULT_OK) { + when (requestCode) { + REQUEST_CODE_CHOOSE_FILE -> { try { checkNotNull(intent) filesToUpload.clear() - run { + intent.clipData?.let { + for (index in 0 until it.itemCount) { + filesToUpload.add(it.getItemAt(index).uri.toString()) + } + } ?: run { checkNotNull(intent.data) intent.data.let { filesToUpload.add(intent.data.toString()) @@ -1439,11 +1368,37 @@ class ChatController(args: Bundle) : } require(filesToUpload.isNotEmpty()) - if (UploadAndShareFilesWorker.isStoragePermissionGranted(context!!)) { - uploadFiles(filesToUpload, false) - } else { - UploadAndShareFilesWorker.requestStoragePermission(this) + val filenamesWithLinebreaks = StringBuilder("\n") + + for (file in filesToUpload) { + val filename = UriUtils.getFileName(Uri.parse(file), context) + filenamesWithLinebreaks.append(filename).append("\n") } + + val confirmationQuestion = when (filesToUpload.size) { + 1 -> context?.resources?.getString(R.string.nc_upload_confirm_send_single)?.let { + String.format(it, title) + } + else -> context?.resources?.getString(R.string.nc_upload_confirm_send_multiple)?.let { + String.format(it, title) + } + } + + LovelyStandardDialog(activity) + .setPositiveButtonColorRes(R.color.nc_darkGreen) + .setTitle(confirmationQuestion) + .setMessage(filenamesWithLinebreaks.toString()) + .setPositiveButton(R.string.nc_yes) { v -> + if (UploadAndShareFilesWorker.isStoragePermissionGranted(context!!)) { + uploadFiles(filesToUpload, false) + } else { + UploadAndShareFilesWorker.requestStoragePermission(this) + } + } + .setNegativeButton(R.string.nc_no) { + // unused atm + } + .show() } catch (e: IllegalStateException) { Toast.makeText(context, context?.resources?.getString(R.string.nc_upload_failed), Toast.LENGTH_LONG) .show() @@ -1454,8 +1409,79 @@ class ChatController(args: Bundle) : Log.e(javaClass.simpleName, "Something went wrong when trying to upload file", e) } } - } else if (requestCode == REQUEST_CODE_MESSAGE_SEARCH) { - TODO() + REQUEST_CODE_SELECT_CONTACT -> { + val contactUri = intent?.data ?: return + val cursor: Cursor? = activity?.contentResolver!!.query(contactUri, null, null, null, null) + + if (cursor != null && cursor.moveToFirst()) { + val id = cursor.getString(cursor.getColumnIndexOrThrow(ContactsContract.Contacts._ID)) + val fileName = ContactUtils.getDisplayNameFromDeviceContact(context!!, id) + ".vcf" + val file = File(context?.cacheDir, fileName) + writeContactToVcfFile(cursor, file) + + val shareUri = FileProvider.getUriForFile( + activity!!, + BuildConfig.APPLICATION_ID, + File(file.absolutePath) + ) + uploadFiles(mutableListOf(shareUri.toString()), false) + } + cursor?.close() + } + REQUEST_CODE_PICK_CAMERA -> { + if (resultCode == RESULT_OK) { + try { + checkNotNull(intent) + filesToUpload.clear() + run { + checkNotNull(intent.data) + intent.data.let { + filesToUpload.add(intent.data.toString()) + } + } + require(filesToUpload.isNotEmpty()) + + if (UploadAndShareFilesWorker.isStoragePermissionGranted(context!!)) { + uploadFiles(filesToUpload, false) + } else { + UploadAndShareFilesWorker.requestStoragePermission(this) + } + } catch (e: IllegalStateException) { + Toast.makeText( + context, + context?.resources?.getString(R.string.nc_upload_failed), + Toast.LENGTH_LONG + ) + .show() + Log.e(javaClass.simpleName, "Something went wrong when trying to upload file", e) + } catch (e: IllegalArgumentException) { + Toast.makeText( + context, + context?.resources?.getString(R.string.nc_upload_failed), + Toast.LENGTH_LONG + ) + .show() + Log.e(javaClass.simpleName, "Something went wrong when trying to upload file", e) + } + } + } + REQUEST_CODE_MESSAGE_SEARCH -> { + val messageId = intent?.getStringExtra(MessageSearchActivity.RESULT_KEY_MESSAGE_ID) + messageId?.let { id -> + scrollToMessageWithId(id) + } + } + } + } + + private fun scrollToMessageWithId(messageId: String) { + val position = adapter?.items?.indexOfFirst { + it.item is ChatMessage && (it.item as ChatMessage).id == messageId + } + if (position != null && position >= 0) { + binding.messagesListView.smoothScrollToPosition(position) + } else { + // TODO show error that we don't have that message? } } @@ -2283,6 +2309,7 @@ class ChatController(args: Bundle) : if (adapter != null) { adapter?.addToEnd(chatMessageList, false) } + scrollToRequestedMessageIfNeeded() } else { var chatMessage: ChatMessage @@ -2398,6 +2425,12 @@ class ChatController(args: Bundle) : } } + private fun scrollToRequestedMessageIfNeeded() { + args.getString(BundleKeys.KEY_MESSAGE_ID)?.let { + scrollToMessageWithId(it) + } + } + private fun isSameDayNonSystemMessages(messageLeft: ChatMessage, messageRight: ChatMessage): Boolean { return TextUtils.isEmpty(messageLeft.systemMessage) && TextUtils.isEmpty(messageRight.systemMessage) && @@ -2515,7 +2548,7 @@ class ChatController(args: Bundle) : intent.putExtra(KEY_CONVERSATION_NAME, currentConversation?.displayName) intent.putExtra(KEY_ROOM_TOKEN, roomToken) intent.putExtra(KEY_USER_ENTITY, conversationUser as Parcelable) - activity!!.startActivityForResult(intent, REQUEST_CODE_MESSAGE_SEARCH) + startActivityForResult(intent, REQUEST_CODE_MESSAGE_SEARCH) } private fun handleSystemMessages(chatMessageList: List): List { 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 c76341bf6..a6065a54e 100644 --- a/app/src/main/java/com/nextcloud/talk/controllers/ConversationsListController.java +++ b/app/src/main/java/com/nextcloud/talk/controllers/ConversationsListController.java @@ -74,7 +74,6 @@ 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.messagesearch.MessageSearchHelper; import com.nextcloud.talk.events.ConversationsListFetchDataEvent; import com.nextcloud.talk.events.EventStatus; import com.nextcloud.talk.interfaces.ConversationMenuInterface; @@ -82,6 +81,7 @@ import com.nextcloud.talk.jobs.AccountRemovalWorker; import com.nextcloud.talk.jobs.ContactAddressBookWorker; import com.nextcloud.talk.jobs.DeleteConversationWorker; import com.nextcloud.talk.jobs.UploadAndShareFilesWorker; +import com.nextcloud.talk.messagesearch.MessageSearchHelper; import com.nextcloud.talk.models.database.CapabilitiesUtil; import com.nextcloud.talk.models.database.UserEntity; import com.nextcloud.talk.models.domain.SearchMessageEntry; @@ -223,6 +223,7 @@ public class ConversationsListController extends BaseController implements Flexi private Conversation selectedConversation; private String textToPaste = ""; + private String selectedMessageId = null; private boolean forwardMessage; @@ -921,7 +922,9 @@ public class ConversationsListController extends BaseController implements Flexi @SuppressLint("CheckResult") // handled by helper private void startMessageSearch(final String search) { - swipeRefreshLayout.setRefreshing(true); + if (swipeRefreshLayout != null) { + swipeRefreshLayout.setRefreshing(true); + } searchHelper .startMessageSearch(search) .subscribeOn(Schedulers.io()) @@ -958,6 +961,7 @@ public class ConversationsListController extends BaseController implements Flexi } else if (item instanceof MessageResultItem) { MessageResultItem messageItem = (MessageResultItem) item; String conversationToken = messageItem.getMessageEntry().getConversationToken(); + selectedMessageId = messageItem.getMessageEntry().getMessageId(); showConversationByToken(conversationToken); } else if (item instanceof LoadMoreResultsItem) { loadMoreMessages(); @@ -1187,6 +1191,10 @@ public class ConversationsListController extends BaseController implements Flexi bundle.putString(BundleKeys.INSTANCE.getKEY_ROOM_TOKEN(), selectedConversation.getToken()); bundle.putString(BundleKeys.INSTANCE.getKEY_ROOM_ID(), selectedConversation.getRoomId()); bundle.putString(BundleKeys.INSTANCE.getKEY_SHARED_TEXT(), textToPaste); + if (selectedMessageId != null) { + bundle.putString(BundleKeys.KEY_MESSAGE_ID, selectedMessageId); + selectedMessageId = null; + } ConductorRemapping.INSTANCE.remapChatController(getRouter(), currentUser.getId(), selectedConversation.getToken(), bundle, false); @@ -1394,11 +1402,15 @@ public class ConversationsListController extends BaseController implements Flexi recyclerView.scrollToPosition(0); } } - swipeRefreshLayout.setRefreshing(false); + if (swipeRefreshLayout != null) { + swipeRefreshLayout.setRefreshing(false); + } } public void onMessageSearchError(@NonNull Throwable throwable) { handleHttpExceptions(throwable); - swipeRefreshLayout.setRefreshing(false); + if (swipeRefreshLayout != null) { + swipeRefreshLayout.setRefreshing(false); + } } } diff --git a/app/src/main/java/com/nextcloud/talk/messagesearch/MessageSearchActivity.kt b/app/src/main/java/com/nextcloud/talk/messagesearch/MessageSearchActivity.kt index 5c84c292a..090e36fcb 100644 --- a/app/src/main/java/com/nextcloud/talk/messagesearch/MessageSearchActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/messagesearch/MessageSearchActivity.kt @@ -22,6 +22,7 @@ package com.nextcloud.talk.messagesearch import android.app.Activity +import android.content.Intent import android.os.Bundle import android.text.TextUtils import android.view.Menu @@ -154,14 +155,29 @@ class MessageSearchActivity : BaseActivity() { adapter!!.addListener(object : FlexibleAdapter.OnItemClickListener { override fun onItemClick(view: View?, position: Int): Boolean { val item = adapter!!.getItem(position) - if (item?.itemViewType == LoadMoreResultsItem.VIEW_TYPE) { - viewModel.loadMore() + when (item?.itemViewType) { + LoadMoreResultsItem.VIEW_TYPE -> { + viewModel.loadMore() + } + MessageResultItem.VIEW_TYPE -> { + // TODO go through viewmodel + val messageItem = item as MessageResultItem + finishWithResult(messageItem.messageEntry.messageId!!) + } } return false } }) } + private fun finishWithResult(messageId: String) { + val resultIntent = Intent().apply { + putExtra(RESULT_KEY_MESSAGE_ID, messageId) + } + setResult(Activity.RESULT_OK, resultIntent) + finish() + } + private fun showInitial() { binding.messageSearchRecycler.visibility = View.GONE binding.emptyContainer.emptyListViewHeadline.text = "Start typing to search..." @@ -235,4 +251,8 @@ class MessageSearchActivity : BaseActivity() { super.onDestroy() searchViewDisposable?.dispose() } + + companion object { + const val RESULT_KEY_MESSAGE_ID = "MessageSearchActivity.result.message" + } } diff --git a/app/src/main/java/com/nextcloud/talk/utils/bundle/BundleKeys.kt b/app/src/main/java/com/nextcloud/talk/utils/bundle/BundleKeys.kt index eac2cf190..02e1b0abe 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/bundle/BundleKeys.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/bundle/BundleKeys.kt @@ -73,4 +73,5 @@ object BundleKeys { val KEY_FORWARD_MSG_TEXT = "KEY_FORWARD_MSG_TEXT" val KEY_FORWARD_HIDE_SOURCE_ROOM = "KEY_FORWARD_HIDE_SOURCE_ROOM" val KEY_SYSTEM_NOTIFICATION_ID = "KEY_SYSTEM_NOTIFICATION_ID" + const val KEY_MESSAGE_ID = "KEY_MESSAGE_ID" } From 0d21ce4f1751021f2525012233cd6587a39f8fe0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Brey?= Date: Mon, 30 May 2022 13:36:43 +0200 Subject: [PATCH 06/12] MessageSearchActivity: add loading animation + swipe to refresh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Álvaro Brey --- .../messagesearch/MessageSearchActivity.kt | 17 +++++++++++--- .../messagesearch/MessageSearchViewModel.kt | 4 ++++ .../res/layout/activity_message_search.xml | 22 +++++++++++++------ 3 files changed, 33 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/messagesearch/MessageSearchActivity.kt b/app/src/main/java/com/nextcloud/talk/messagesearch/MessageSearchActivity.kt index 090e36fcb..705f226cc 100644 --- a/app/src/main/java/com/nextcloud/talk/messagesearch/MessageSearchActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/messagesearch/MessageSearchActivity.kt @@ -84,6 +84,10 @@ class MessageSearchActivity : BaseActivity() { val roomToken = intent.getStringExtra(BundleKeys.KEY_ROOM_TOKEN)!! viewModel.initialize(user, roomToken) setupStateObserver() + + binding.swipeRefreshLayout.setOnRefreshListener { + viewModel.refresh(searchView.query?.toString()) + } } private fun setupActionBar() { @@ -109,8 +113,8 @@ class MessageSearchActivity : BaseActivity() { private fun setupStateObserver() { viewModel.state.observe(this) { state -> when (state) { - MessageSearchViewModel.EmptyState -> showEmpty() MessageSearchViewModel.InitialState -> showInitial() + MessageSearchViewModel.EmptyState -> showEmpty() is MessageSearchViewModel.LoadedState -> showLoaded(state) MessageSearchViewModel.LoadingState -> showLoading() MessageSearchViewModel.ErrorState -> showError() @@ -119,15 +123,20 @@ class MessageSearchActivity : BaseActivity() { } private fun showError() { + displayLoading(false) Toast.makeText(this, "Error while searching", Toast.LENGTH_SHORT).show() } private fun showLoading() { - // TODO - Toast.makeText(this, "LOADING", Toast.LENGTH_LONG).show() + displayLoading(true) + } + + private fun displayLoading(loading: Boolean) { + binding.swipeRefreshLayout.isRefreshing = loading } private fun showLoaded(state: MessageSearchViewModel.LoadedState) { + displayLoading(false) binding.emptyContainer.emptyListView.visibility = View.GONE binding.messageSearchRecycler.visibility = View.VISIBLE setAdapterItems(state) @@ -179,12 +188,14 @@ class MessageSearchActivity : BaseActivity() { } private fun showInitial() { + displayLoading(false) binding.messageSearchRecycler.visibility = View.GONE binding.emptyContainer.emptyListViewHeadline.text = "Start typing to search..." binding.emptyContainer.emptyListView.visibility = View.VISIBLE } private fun showEmpty() { + displayLoading(false) binding.messageSearchRecycler.visibility = View.GONE binding.emptyContainer.emptyListViewHeadline.text = "No search results" binding.emptyContainer.emptyListView.visibility = View.VISIBLE diff --git a/app/src/main/java/com/nextcloud/talk/messagesearch/MessageSearchViewModel.kt b/app/src/main/java/com/nextcloud/talk/messagesearch/MessageSearchViewModel.kt index a7b0acd9e..3ce532cfa 100644 --- a/app/src/main/java/com/nextcloud/talk/messagesearch/MessageSearchViewModel.kt +++ b/app/src/main/java/com/nextcloud/talk/messagesearch/MessageSearchViewModel.kt @@ -107,6 +107,10 @@ class MessageSearchViewModel @Inject constructor(private val unifiedSearchReposi _state.value = ErrorState } + fun refresh(query: String?) { + query?.let { onQueryTextChange(it) } + } + companion object { private val TAG = MessageSearchViewModel::class.simpleName private const val MIN_CHARS_FOR_SEARCH = 2 diff --git a/app/src/main/res/layout/activity_message_search.xml b/app/src/main/res/layout/activity_message_search.xml index 5c7ddf74c..731a31058 100644 --- a/app/src/main/res/layout/activity_message_search.xml +++ b/app/src/main/res/layout/activity_message_search.xml @@ -41,8 +41,7 @@ app:navigationIconTint="@color/fontAppbar" app:popupTheme="@style/appActionBarPopupMenu" app:titleTextColor="@color/fontAppbar" - tools:title="@string/nc_app_product_name"> - + tools:title="@string/nc_app_product_name"> @@ -52,12 +51,21 @@ android:visibility="gone" tools:visibility="visible" /> - + app:layout_behavior="com.nextcloud.talk.utils.FABAwareScrollingViewBehavior"> + + + + From 1f00f426c78da61e4b969cecf36434caefdd8649 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Brey?= Date: Mon, 30 May 2022 13:44:40 +0200 Subject: [PATCH 07/12] MessageSearchActivity: don't skip viewmodel when selecting message MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Álvaro Brey --- .../talk/messagesearch/MessageSearchActivity.kt | 17 ++++++++++------- .../messagesearch/MessageSearchViewModel.kt | 5 +++++ 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/messagesearch/MessageSearchActivity.kt b/app/src/main/java/com/nextcloud/talk/messagesearch/MessageSearchActivity.kt index 705f226cc..73aa57d50 100644 --- a/app/src/main/java/com/nextcloud/talk/messagesearch/MessageSearchActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/messagesearch/MessageSearchActivity.kt @@ -118,6 +118,7 @@ class MessageSearchActivity : BaseActivity() { is MessageSearchViewModel.LoadedState -> showLoaded(state) MessageSearchViewModel.LoadingState -> showLoading() MessageSearchViewModel.ErrorState -> showError() + is MessageSearchViewModel.FinishedState -> onFinish() } } } @@ -169,9 +170,8 @@ class MessageSearchActivity : BaseActivity() { viewModel.loadMore() } MessageResultItem.VIEW_TYPE -> { - // TODO go through viewmodel val messageItem = item as MessageResultItem - finishWithResult(messageItem.messageEntry.messageId!!) + viewModel.selectMessage(messageItem.messageEntry) } } return false @@ -179,12 +179,15 @@ class MessageSearchActivity : BaseActivity() { }) } - private fun finishWithResult(messageId: String) { - val resultIntent = Intent().apply { - putExtra(RESULT_KEY_MESSAGE_ID, messageId) + private fun onFinish() { + val state = viewModel.state.value + if (state is MessageSearchViewModel.FinishedState) { + val resultIntent = Intent().apply { + putExtra(RESULT_KEY_MESSAGE_ID, state.selectedMessageId) + } + setResult(Activity.RESULT_OK, resultIntent) + finish() } - setResult(Activity.RESULT_OK, resultIntent) - finish() } private fun showInitial() { diff --git a/app/src/main/java/com/nextcloud/talk/messagesearch/MessageSearchViewModel.kt b/app/src/main/java/com/nextcloud/talk/messagesearch/MessageSearchViewModel.kt index 3ce532cfa..fce06e2c2 100644 --- a/app/src/main/java/com/nextcloud/talk/messagesearch/MessageSearchViewModel.kt +++ b/app/src/main/java/com/nextcloud/talk/messagesearch/MessageSearchViewModel.kt @@ -58,6 +58,7 @@ class MessageSearchViewModel @Inject constructor(private val unifiedSearchReposi object EmptyState : ViewState() object ErrorState : ViewState() class LoadedState(val results: List, val hasMore: Boolean) : ViewState() + class FinishedState(val selectedMessageId: String) : ViewState() private lateinit var messageSearchHelper: MessageSearchHelper @@ -111,6 +112,10 @@ class MessageSearchViewModel @Inject constructor(private val unifiedSearchReposi query?.let { onQueryTextChange(it) } } + fun selectMessage(messageEntry: SearchMessageEntry) { + _state.value = FinishedState(messageEntry.messageId!!) + } + companion object { private val TAG = MessageSearchViewModel::class.simpleName private const val MIN_CHARS_FOR_SEARCH = 2 From 232334efaca10fbb11458bb86585dae1d2ac496e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Brey?= Date: Mon, 30 May 2022 15:43:12 +0200 Subject: [PATCH 08/12] Fix spotbugs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Álvaro Brey --- .../talk/adapters/items/ConversationItem.java | 8 +++++ .../ConversationsListController.java | 31 +++++++++---------- 2 files changed, 23 insertions(+), 16 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/adapters/items/ConversationItem.java b/app/src/main/java/com/nextcloud/talk/adapters/items/ConversationItem.java index 104523f8a..fcb706309 100644 --- a/app/src/main/java/com/nextcloud/talk/adapters/items/ConversationItem.java +++ b/app/src/main/java/com/nextcloud/talk/adapters/items/ConversationItem.java @@ -67,6 +67,8 @@ import eu.davidea.viewholders.FlexibleViewHolder; public class ConversationItem extends AbstractFlexibleItem implements ISectionable, IFilterable { + public static final int VIEW_TYPE = R.layout.rv_item_conversation_with_last_message; + private static final float STATUS_SIZE_IN_DP = 9f; private final Conversation conversation; @@ -75,6 +77,7 @@ public class ConversationItem extends AbstractFlexibleItem adapter) { return new ConversationItemViewHolder(view, adapter); 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 a6065a54e..de5e006ce 100644 --- a/app/src/main/java/com/nextcloud/talk/controllers/ConversationsListController.java +++ b/app/src/main/java/com/nextcloud/talk/controllers/ConversationsListController.java @@ -956,31 +956,30 @@ public class ConversationsListController extends BaseController implements Flexi @Override public boolean onItemClick(View view, int position) { 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(); - selectedMessageId = messageItem.getMessageEntry().getMessageId(); - showConversationByToken(conversationToken); - } else if (item instanceof LoadMoreResultsItem) { - loadMoreMessages(); + if (item != null) { + final int viewType = item.getItemViewType(); + if (viewType == MessageResultItem.VIEW_TYPE) { + MessageResultItem messageItem = (MessageResultItem) item; + String conversationToken = messageItem.getMessageEntry().getConversationToken(); + selectedMessageId = messageItem.getMessageEntry().getMessageId(); + showConversationByToken(conversationToken); + } else if (viewType == LoadMoreResultsItem.VIEW_TYPE) { + loadMoreMessages(); + } else if (viewType == ConversationItem.VIEW_TYPE) { + showConversation(((ConversationItem) Objects.requireNonNull(item)).getModel()); + } } - 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(); + final Conversation conversation = conversationItem.getModel(); + showConversation(conversation); } } - if (conversation != null) { - showConversation(conversation); - } } private void showConversation(@Nullable final Conversation conversation) { @@ -1390,7 +1389,7 @@ public class ConversationsListController extends BaseController implements Flexi clearMessageSearchResults(); final List entries = results.getMessages(); if (entries.size() > 0) { - List adapterItems = new ArrayList<>(); + List adapterItems = new ArrayList<>(entries.size() + 1); for (int i = 0; i < entries.size(); i++) { final boolean showHeader = i == 0; adapterItems.add(new MessageResultItem(context, currentUser, entries.get(i), showHeader)); From c10c45630ca3dbc7eb3cdabf38db67be782c02e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Brey?= Date: Mon, 30 May 2022 16:12:28 +0200 Subject: [PATCH 09/12] Fix some lint issues MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Álvaro Brey --- app/src/main/AndroidManifest.xml | 3 +-- .../talk/messagesearch/MessageSearchActivity.kt | 6 +++--- app/src/main/res/layout/activity_message_search.xml | 1 + app/src/main/res/values/strings.xml | 10 +++++++--- 4 files changed, 12 insertions(+), 8 deletions(-) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 2aa763474..a098c8ff9 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -174,8 +174,7 @@ + android:theme="@style/AppTheme" /> diff --git a/app/src/main/java/com/nextcloud/talk/messagesearch/MessageSearchActivity.kt b/app/src/main/java/com/nextcloud/talk/messagesearch/MessageSearchActivity.kt index 73aa57d50..4fb9cd087 100644 --- a/app/src/main/java/com/nextcloud/talk/messagesearch/MessageSearchActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/messagesearch/MessageSearchActivity.kt @@ -193,14 +193,14 @@ class MessageSearchActivity : BaseActivity() { private fun showInitial() { displayLoading(false) binding.messageSearchRecycler.visibility = View.GONE - binding.emptyContainer.emptyListViewHeadline.text = "Start typing to search..." + binding.emptyContainer.emptyListViewHeadline.text = getString(R.string.message_search_begin_typing) binding.emptyContainer.emptyListView.visibility = View.VISIBLE } private fun showEmpty() { displayLoading(false) binding.messageSearchRecycler.visibility = View.GONE - binding.emptyContainer.emptyListViewHeadline.text = "No search results" + binding.emptyContainer.emptyListViewHeadline.text = getString(R.string.message_search_begin_empty) binding.emptyContainer.emptyListView.visibility = View.VISIBLE } @@ -229,7 +229,7 @@ class MessageSearchActivity : BaseActivity() { } private fun setupSearchView() { - searchView.queryHint = getString(R.string.nc_search_hint) + searchView.queryHint = getString(R.string.message_search_hint) searchViewDisposable = observeSearchView(searchView) .debounce { query -> when { diff --git a/app/src/main/res/layout/activity_message_search.xml b/app/src/main/res/layout/activity_message_search.xml index 731a31058..f7c373f13 100644 --- a/app/src/main/res/layout/activity_message_search.xml +++ b/app/src/main/res/layout/activity_message_search.xml @@ -24,6 +24,7 @@ android:layout_width="match_parent" android:layout_height="match_parent" android:background="@color/bg_default" + tools:ignore="Overdraw" tools:context=".messagesearch.MessageSearchActivity"> Voice Other + + Messages + Load more results + Search… + Start typing to search… + No search results + Attachments All Send without notification Call without notification - Messages - Load more results - Search… From eddb90d31be43c11f1aa60c2665d7b73d5ac1935 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Brey?= Date: Tue, 31 May 2022 14:55:25 +0200 Subject: [PATCH 10/12] Message search: avoid passing user entity to repository, inject userProvider instead MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Álvaro Brey --- .../application/NextcloudTalkApplication.kt | 1 - .../talk/controllers/ChatController.kt | 1 - .../ConversationsListController.java | 2 +- .../talk/dagger/modules/RepositoryModule.kt | 5 +-- .../messagesearch/MessageSearchActivity.kt | 8 +++-- .../talk/messagesearch/MessageSearchHelper.kt | 4 --- .../messagesearch/MessageSearchViewModel.kt | 8 ++--- .../unifiedsearch/UnifiedSearchRepository.kt | 3 -- .../UnifiedSearchRepositoryImpl.kt | 16 ++++++--- .../database/user/CurrentUserProvider.kt | 27 ++++++++++++++ .../user/{UserModule.java => UserModule.kt} | 35 +++++++++---------- .../talk/utils/database/user/UserUtils.java | 3 +- .../test/fakes/FakeUnifiedSearchRepository.kt | 3 -- 13 files changed, 68 insertions(+), 48 deletions(-) create mode 100644 app/src/main/java/com/nextcloud/talk/utils/database/user/CurrentUserProvider.kt rename app/src/main/java/com/nextcloud/talk/utils/database/user/{UserModule.java => UserModule.kt} (54%) diff --git a/app/src/main/java/com/nextcloud/talk/application/NextcloudTalkApplication.kt b/app/src/main/java/com/nextcloud/talk/application/NextcloudTalkApplication.kt index dc304b8db..432dbc807 100644 --- a/app/src/main/java/com/nextcloud/talk/application/NextcloudTalkApplication.kt +++ b/app/src/main/java/com/nextcloud/talk/application/NextcloudTalkApplication.kt @@ -213,7 +213,6 @@ class NextcloudTalkApplication : MultiDexApplication(), LifecycleObserver { .contextModule(ContextModule(applicationContext)) .databaseModule(DatabaseModule()) .restModule(RestModule(applicationContext)) - .userModule(UserModule()) .arbitraryStorageModule(ArbitraryStorageModule()) .build() } diff --git a/app/src/main/java/com/nextcloud/talk/controllers/ChatController.kt b/app/src/main/java/com/nextcloud/talk/controllers/ChatController.kt index dccabdd6c..e17b0d218 100644 --- a/app/src/main/java/com/nextcloud/talk/controllers/ChatController.kt +++ b/app/src/main/java/com/nextcloud/talk/controllers/ChatController.kt @@ -2547,7 +2547,6 @@ class ChatController(args: Bundle) : val intent = Intent(activity, MessageSearchActivity::class.java) intent.putExtra(KEY_CONVERSATION_NAME, currentConversation?.displayName) intent.putExtra(KEY_ROOM_TOKEN, roomToken) - intent.putExtra(KEY_USER_ENTITY, conversationUser as Parcelable) startActivityForResult(intent, REQUEST_CODE_MESSAGE_SEARCH) } 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 de5e006ce..dcd3194ea 100644 --- a/app/src/main/java/com/nextcloud/talk/controllers/ConversationsListController.java +++ b/app/src/main/java/com/nextcloud/talk/controllers/ConversationsListController.java @@ -326,7 +326,7 @@ public class ConversationsListController extends BaseController implements Flexi return; } - searchHelper = new MessageSearchHelper(currentUser, unifiedSearchRepository); + searchHelper = new MessageSearchHelper(unifiedSearchRepository); credentials = ApiUtils.getCredentials(currentUser.getUsername(), currentUser.getToken()); if (getActivity() != null && getActivity() instanceof MainActivity) { 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 79d3489de..0e62a8645 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 @@ -26,6 +26,7 @@ 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 com.nextcloud.talk.utils.database.user.CurrentUserProvider import dagger.Module import dagger.Provides @@ -37,7 +38,7 @@ class RepositoryModule { } @Provides - fun provideUnifiedSearchRepository(ncApi: NcApi): UnifiedSearchRepository { - return UnifiedSearchRepositoryImpl(ncApi) + fun provideUnifiedSearchRepository(ncApi: NcApi, userProvider: CurrentUserProvider): UnifiedSearchRepository { + return UnifiedSearchRepositoryImpl(ncApi, userProvider) } } diff --git a/app/src/main/java/com/nextcloud/talk/messagesearch/MessageSearchActivity.kt b/app/src/main/java/com/nextcloud/talk/messagesearch/MessageSearchActivity.kt index 4fb9cd087..ac2501522 100644 --- a/app/src/main/java/com/nextcloud/talk/messagesearch/MessageSearchActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/messagesearch/MessageSearchActivity.kt @@ -43,6 +43,7 @@ import com.nextcloud.talk.databinding.ActivityMessageSearchBinding import com.nextcloud.talk.models.database.UserEntity import com.nextcloud.talk.utils.DisplayUtils import com.nextcloud.talk.utils.bundle.BundleKeys +import com.nextcloud.talk.utils.database.user.CurrentUserProvider import com.nextcloud.talk.utils.rx.SearchViewObservable.Companion.observeSearchView import eu.davidea.flexibleadapter.FlexibleAdapter import eu.davidea.flexibleadapter.items.AbstractFlexibleItem @@ -60,6 +61,9 @@ class MessageSearchActivity : BaseActivity() { @Inject lateinit var viewModelFactory: ViewModelProvider.Factory + @Inject + lateinit var userProvider: CurrentUserProvider + private lateinit var binding: ActivityMessageSearchBinding private lateinit var searchView: SearchView @@ -80,9 +84,9 @@ class MessageSearchActivity : BaseActivity() { setContentView(binding.root) viewModel = ViewModelProvider(this, viewModelFactory)[MessageSearchViewModel::class.java] - user = intent.getParcelableExtra(BundleKeys.KEY_USER_ENTITY)!! + user = userProvider.currentUser!! val roomToken = intent.getStringExtra(BundleKeys.KEY_ROOM_TOKEN)!! - viewModel.initialize(user, roomToken) + viewModel.initialize(roomToken) setupStateObserver() binding.swipeRefreshLayout.setOnRefreshListener { diff --git a/app/src/main/java/com/nextcloud/talk/messagesearch/MessageSearchHelper.kt b/app/src/main/java/com/nextcloud/talk/messagesearch/MessageSearchHelper.kt index 0350a9f73..e303f1d2d 100644 --- a/app/src/main/java/com/nextcloud/talk/messagesearch/MessageSearchHelper.kt +++ b/app/src/main/java/com/nextcloud/talk/messagesearch/MessageSearchHelper.kt @@ -22,14 +22,12 @@ package com.nextcloud.talk.messagesearch 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 @JvmOverloads constructor( - private val user: UserEntity, private val unifiedSearchRepository: UnifiedSearchRepository, private val fromRoom: String? = null ) { @@ -84,7 +82,6 @@ class MessageSearchHelper @JvmOverloads constructor( return when { fromRoom != null -> { unifiedSearchRepository.searchInRoom( - userEntity = user, roomToken = fromRoom, searchTerm = search, cursor = cursor @@ -92,7 +89,6 @@ class MessageSearchHelper @JvmOverloads constructor( } else -> { unifiedSearchRepository.searchMessages( - userEntity = user, searchTerm = search, cursor = cursor ) diff --git a/app/src/main/java/com/nextcloud/talk/messagesearch/MessageSearchViewModel.kt b/app/src/main/java/com/nextcloud/talk/messagesearch/MessageSearchViewModel.kt index fce06e2c2..1864070e8 100644 --- a/app/src/main/java/com/nextcloud/talk/messagesearch/MessageSearchViewModel.kt +++ b/app/src/main/java/com/nextcloud/talk/messagesearch/MessageSearchViewModel.kt @@ -26,11 +26,9 @@ import android.util.Log import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel -import com.nextcloud.talk.models.database.UserEntity import com.nextcloud.talk.models.domain.SearchMessageEntry import com.nextcloud.talk.repositories.unifiedsearch.UnifiedSearchRepository import io.reactivex.android.schedulers.AndroidSchedulers -import io.reactivex.disposables.Disposable import io.reactivex.schedulers.Schedulers import javax.inject.Inject @@ -66,10 +64,8 @@ class MessageSearchViewModel @Inject constructor(private val unifiedSearchReposi val state: LiveData get() = _state - private var searchDisposable: Disposable? = null - - fun initialize(user: UserEntity, roomToken: String) { - messageSearchHelper = MessageSearchHelper(user, unifiedSearchRepository, roomToken) + fun initialize(roomToken: String) { + messageSearchHelper = MessageSearchHelper(unifiedSearchRepository, roomToken) } @SuppressLint("CheckResult") // handled by helper 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 index 4d91e1219..a24e86bdd 100644 --- a/app/src/main/java/com/nextcloud/talk/repositories/unifiedsearch/UnifiedSearchRepository.kt +++ b/app/src/main/java/com/nextcloud/talk/repositories/unifiedsearch/UnifiedSearchRepository.kt @@ -1,6 +1,5 @@ package com.nextcloud.talk.repositories.unifiedsearch -import com.nextcloud.talk.models.database.UserEntity import com.nextcloud.talk.models.domain.SearchMessageEntry import io.reactivex.Observable @@ -12,14 +11,12 @@ interface UnifiedSearchRepository { ) fun searchMessages( - userEntity: UserEntity, searchTerm: String, cursor: Int = 0, limit: Int = DEFAULT_PAGE_SIZE ): Observable> fun searchInRoom( - userEntity: UserEntity, roomToken: String, searchTerm: String, cursor: Int = 0, 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 index 1f0d072c3..39623b3a2 100644 --- a/app/src/main/java/com/nextcloud/talk/repositories/unifiedsearch/UnifiedSearchRepositoryImpl.kt +++ b/app/src/main/java/com/nextcloud/talk/repositories/unifiedsearch/UnifiedSearchRepositoryImpl.kt @@ -27,18 +27,25 @@ 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 com.nextcloud.talk.utils.database.user.CurrentUserProvider import io.reactivex.Observable -class UnifiedSearchRepositoryImpl(private val api: NcApi) : UnifiedSearchRepository { +class UnifiedSearchRepositoryImpl(private val api: NcApi, private val userProvider: CurrentUserProvider) : + UnifiedSearchRepository { + + private val userEntity: UserEntity + get() = userProvider.currentUser!! + + private val credentials: String + get() = ApiUtils.getCredentials(userEntity.username, userEntity.token) override fun searchMessages( - userEntity: UserEntity, searchTerm: String, cursor: Int, limit: Int ): Observable> { val apiObservable = api.performUnifiedSearch( - ApiUtils.getCredentials(userEntity.username, userEntity.token), + credentials, ApiUtils.getUrlForUnifiedSearch(userEntity.baseUrl, PROVIDER_TALK_MESSAGE), searchTerm, null, @@ -49,14 +56,13 @@ class UnifiedSearchRepositoryImpl(private val api: NcApi) : UnifiedSearchReposit } override fun searchInRoom( - userEntity: UserEntity, roomToken: String, searchTerm: String, cursor: Int, limit: Int ): Observable> { val apiObservable = api.performUnifiedSearch( - ApiUtils.getCredentials(userEntity.username, userEntity.token), + credentials, ApiUtils.getUrlForUnifiedSearch(userEntity.baseUrl, PROVIDER_TALK_MESSAGE_CURRENT), searchTerm, fromUrlForRoom(roomToken), diff --git a/app/src/main/java/com/nextcloud/talk/utils/database/user/CurrentUserProvider.kt b/app/src/main/java/com/nextcloud/talk/utils/database/user/CurrentUserProvider.kt new file mode 100644 index 000000000..31200ab34 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/utils/database/user/CurrentUserProvider.kt @@ -0,0 +1,27 @@ +/* + * 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.database.user + +import com.nextcloud.talk.models.database.UserEntity + +interface CurrentUserProvider { + val currentUser: UserEntity? +} diff --git a/app/src/main/java/com/nextcloud/talk/utils/database/user/UserModule.java b/app/src/main/java/com/nextcloud/talk/utils/database/user/UserModule.kt similarity index 54% rename from app/src/main/java/com/nextcloud/talk/utils/database/user/UserModule.java rename to app/src/main/java/com/nextcloud/talk/utils/database/user/UserModule.kt index a2bfee62c..aad73490a 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/database/user/UserModule.java +++ b/app/src/main/java/com/nextcloud/talk/utils/database/user/UserModule.kt @@ -17,28 +17,25 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -package com.nextcloud.talk.utils.database.user; +package com.nextcloud.talk.utils.database.user -import autodagger.AutoInjector; -import com.nextcloud.talk.application.NextcloudTalkApplication; -import com.nextcloud.talk.dagger.modules.DatabaseModule; -import dagger.Module; -import dagger.Provides; -import io.requery.Persistable; -import io.requery.reactivex.ReactiveEntityStore; +import com.nextcloud.talk.dagger.modules.DatabaseModule +import dagger.Binds +import dagger.Module +import dagger.Provides +import io.requery.Persistable +import io.requery.reactivex.ReactiveEntityStore -import javax.inject.Inject; +@Module(includes = [DatabaseModule::class]) +abstract class UserModule { -@Module(includes = DatabaseModule.class) -@AutoInjector(NextcloudTalkApplication.class) -public class UserModule { + @Binds + abstract fun bindCurrentUserProvider(userUtils: UserUtils): CurrentUserProvider - @Inject - public UserModule() { - } - - @Provides - public UserUtils provideUserUtils(ReactiveEntityStore dataStore) { - return new UserUtils(dataStore); + companion object { + @Provides + fun provideUserUtils(dataStore: ReactiveEntityStore?): UserUtils { + return UserUtils(dataStore) + } } } diff --git a/app/src/main/java/com/nextcloud/talk/utils/database/user/UserUtils.java b/app/src/main/java/com/nextcloud/talk/utils/database/user/UserUtils.java index 670f40a41..8ee5a347c 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/database/user/UserUtils.java +++ b/app/src/main/java/com/nextcloud/talk/utils/database/user/UserUtils.java @@ -36,7 +36,7 @@ import io.requery.Persistable; import io.requery.query.Result; import io.requery.reactivex.ReactiveEntityStore; -public class UserUtils { +public class UserUtils implements CurrentUserProvider { private ReactiveEntityStore dataStore; UserUtils(ReactiveEntityStore dataStore) { @@ -83,6 +83,7 @@ public class UserUtils { return null; } + @Override public @Nullable UserEntity getCurrentUser() { Result findUserQueryResult = dataStore.select(User.class).where(UserEntity.CURRENT.eq(Boolean.TRUE) .and(UserEntity.SCHEDULED_FOR_DELETION.notEqual(Boolean.TRUE))) diff --git a/app/src/test/java/com/nextcloud/talk/test/fakes/FakeUnifiedSearchRepository.kt b/app/src/test/java/com/nextcloud/talk/test/fakes/FakeUnifiedSearchRepository.kt index 2a216e69e..33d34f772 100644 --- a/app/src/test/java/com/nextcloud/talk/test/fakes/FakeUnifiedSearchRepository.kt +++ b/app/src/test/java/com/nextcloud/talk/test/fakes/FakeUnifiedSearchRepository.kt @@ -21,7 +21,6 @@ package com.nextcloud.talk.test.fakes -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 @@ -32,7 +31,6 @@ class FakeUnifiedSearchRepository : UnifiedSearchRepository { var lastRequestedCursor = -1 override fun searchMessages( - userEntity: UserEntity, searchTerm: String, cursor: Int, limit: Int @@ -42,7 +40,6 @@ class FakeUnifiedSearchRepository : UnifiedSearchRepository { } override fun searchInRoom( - userEntity: UserEntity, roomToken: String, searchTerm: String, cursor: Int, From 7bfc3f60f192ba337190ebdb965c91ee5e05fc33 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Brey?= Date: Tue, 31 May 2022 17:11:57 +0200 Subject: [PATCH 11/12] Fix MessageSearchHelperTest after removing user parameter from repository MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Álvaro Brey --- .../messagesearch/MessageSearchHelperTest.kt | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/app/src/test/java/com/nextcloud/talk/messagesearch/MessageSearchHelperTest.kt b/app/src/test/java/com/nextcloud/talk/messagesearch/MessageSearchHelperTest.kt index 4dd9a5560..fe760a7e1 100644 --- a/app/src/test/java/com/nextcloud/talk/messagesearch/MessageSearchHelperTest.kt +++ b/app/src/test/java/com/nextcloud/talk/messagesearch/MessageSearchHelperTest.kt @@ -21,7 +21,6 @@ package com.nextcloud.talk.messagesearch -import com.nextcloud.talk.models.database.UserEntity import com.nextcloud.talk.models.domain.SearchMessageEntry import com.nextcloud.talk.repositories.unifiedsearch.UnifiedSearchRepository import com.nextcloud.talk.test.fakes.FakeUnifiedSearchRepository @@ -29,14 +28,10 @@ import io.reactivex.Observable import org.junit.Assert import org.junit.Before import org.junit.Test -import org.mockito.Mock import org.mockito.MockitoAnnotations class MessageSearchHelperTest { - @Mock - lateinit var userEntity: UserEntity - val repository = FakeUnifiedSearchRepository() @Suppress("LongParameterList") @@ -58,7 +53,7 @@ class MessageSearchHelperTest { fun emptySearch() { repository.response = UnifiedSearchRepository.UnifiedSearchResults(0, false, emptyList()) - val sut = MessageSearchHelper(userEntity, repository) + val sut = MessageSearchHelper(repository) val testObserver = sut.startMessageSearch("foo").test() testObserver.assertComplete() @@ -72,7 +67,7 @@ class MessageSearchHelperTest { val entries = (1..5).map { createMessageEntry() } repository.response = UnifiedSearchRepository.UnifiedSearchResults(5, true, entries) - val sut = MessageSearchHelper(userEntity, repository) + val sut = MessageSearchHelper(repository) val observable = sut.startMessageSearch("foo") val expected = MessageSearchHelper.MessageSearchResults(entries, true) @@ -84,7 +79,7 @@ class MessageSearchHelperTest { val entries = (1..2).map { createMessageEntry() } repository.response = UnifiedSearchRepository.UnifiedSearchResults(2, false, entries) - val sut = MessageSearchHelper(userEntity, repository) + val sut = MessageSearchHelper(repository) val observable = sut.startMessageSearch("foo") val expected = MessageSearchHelper.MessageSearchResults(entries, false) @@ -96,7 +91,7 @@ class MessageSearchHelperTest { val entries = (1..2).map { createMessageEntry() } repository.response = UnifiedSearchRepository.UnifiedSearchResults(2, false, entries) - val sut = MessageSearchHelper(userEntity, repository) + val sut = MessageSearchHelper(repository) repeat(5) { val observable = sut.startMessageSearch("foo") @@ -107,13 +102,13 @@ class MessageSearchHelperTest { @Test fun loadMore_noPreviousResults() { - val sut = MessageSearchHelper(userEntity, repository) + val sut = MessageSearchHelper(repository) Assert.assertEquals(null, sut.loadMore()) } @Test fun loadMore_previousResults_sameSearch() { - val sut = MessageSearchHelper(userEntity, repository) + val sut = MessageSearchHelper(repository) val firstPageEntries = (1..5).map { createMessageEntry() } repository.response = UnifiedSearchRepository.UnifiedSearchResults(5, true, firstPageEntries) From b097e3aac4e58dc83e0d10c59738c9442fddd9bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Brey?= Date: Wed, 1 Jun 2022 17:42:59 +0200 Subject: [PATCH 12/12] Message search: disable feature if unified search capability not present MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Álvaro Brey --- .../talk/controllers/ChatController.kt | 2 + .../ConversationsListController.java | 8 +++- .../models/database/CapabilitiesUtil.java | 38 +++++++++++-------- 3 files changed, 30 insertions(+), 18 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/controllers/ChatController.kt b/app/src/main/java/com/nextcloud/talk/controllers/ChatController.kt index e17b0d218..01fa2615a 100644 --- a/app/src/main/java/com/nextcloud/talk/controllers/ChatController.kt +++ b/app/src/main/java/com/nextcloud/talk/controllers/ChatController.kt @@ -2502,6 +2502,8 @@ class ChatController(args: Bundle) : if (CapabilitiesUtil.hasSpreedFeatureCapability(it, "read-only-rooms")) { checkShowCallButtons() } + val searchItem = menu.findItem(R.id.conversation_search) + searchItem.isVisible = CapabilitiesUtil.isUnifiedSearchAvailable(it) } } 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 dcd3194ea..c2fe9e463 100644 --- a/app/src/main/java/com/nextcloud/talk/controllers/ConversationsListController.java +++ b/app/src/main/java/com/nextcloud/talk/controllers/ConversationsListController.java @@ -326,7 +326,9 @@ public class ConversationsListController extends BaseController implements Flexi return; } - searchHelper = new MessageSearchHelper(unifiedSearchRepository); + if (CapabilitiesUtil.isUnifiedSearchAvailable(currentUser)) { + searchHelper = new MessageSearchHelper(unifiedSearchRepository); + } credentials = ApiUtils.getCredentials(currentUser.getUsername(), currentUser.getToken()); if (getActivity() != null && getActivity() instanceof MainActivity) { @@ -898,7 +900,9 @@ public class ConversationsListController extends BaseController implements Flexi clearMessageSearchResults(); adapter.setFilter(filter); adapter.filterItems(); - startMessageSearch(filter); + if (CapabilitiesUtil.isUnifiedSearchAvailable(currentUser)) { + startMessageSearch(filter); + } } else { resetSearchResults(); } diff --git a/app/src/main/java/com/nextcloud/talk/models/database/CapabilitiesUtil.java b/app/src/main/java/com/nextcloud/talk/models/database/CapabilitiesUtil.java index 2e8703b4f..447ffc339 100644 --- a/app/src/main/java/com/nextcloud/talk/models/database/CapabilitiesUtil.java +++ b/app/src/main/java/com/nextcloud/talk/models/database/CapabilitiesUtil.java @@ -30,6 +30,7 @@ import java.io.IOException; import java.util.HashMap; import java.util.Map; +import androidx.annotation.NonNull; import androidx.annotation.Nullable; public abstract class CapabilitiesUtil { @@ -38,7 +39,7 @@ public abstract class CapabilitiesUtil { public static boolean hasNotificationsCapability(@Nullable UserEntity user, String capabilityName) { if (user != null && user.getCapabilities() != null) { try { - Capabilities capabilities = LoganSquare.parse(user.getCapabilities(), Capabilities.class); + Capabilities capabilities = parseUserCapabilities(user); if (capabilities.getNotificationsCapability() != null && capabilities.getNotificationsCapability().getFeatures() != null) { return capabilities.getSpreedCapability().getFeatures().contains(capabilityName); @@ -53,7 +54,7 @@ public abstract class CapabilitiesUtil { public static boolean hasExternalCapability(@Nullable UserEntity user, String capabilityName) { if (user != null && user.getCapabilities() != null) { try { - Capabilities capabilities = LoganSquare.parse(user.getCapabilities(), Capabilities.class); + Capabilities capabilities = parseUserCapabilities(user); if (capabilities.getExternalCapability() != null && capabilities.getExternalCapability().containsKey("v1")) { return capabilities.getExternalCapability().get("v1").contains(capabilityName); @@ -82,7 +83,7 @@ public abstract class CapabilitiesUtil { public static boolean hasSpreedFeatureCapability(@Nullable UserEntity user, String capabilityName) { if (user != null && user.getCapabilities() != null) { try { - Capabilities capabilities = LoganSquare.parse(user.getCapabilities(), Capabilities.class); + Capabilities capabilities = parseUserCapabilities(user); if (capabilities != null && capabilities.getSpreedCapability() != null && capabilities.getSpreedCapability().getFeatures() != null) { return capabilities.getSpreedCapability().getFeatures().contains(capabilityName); @@ -97,7 +98,7 @@ public abstract class CapabilitiesUtil { public static Integer getMessageMaxLength(@Nullable UserEntity user) { if (user != null && user.getCapabilities() != null) { try { - Capabilities capabilities = LoganSquare.parse(user.getCapabilities(), Capabilities.class); + Capabilities capabilities = parseUserCapabilities(user); if (capabilities != null && capabilities.getSpreedCapability() != null && capabilities.getSpreedCapability().getConfig() != null && @@ -125,7 +126,7 @@ public abstract class CapabilitiesUtil { public static boolean isPhoneBookIntegrationAvailable(@Nullable UserEntity user) { if (user != null && user.getCapabilities() != null) { try { - Capabilities capabilities = LoganSquare.parse(user.getCapabilities(), Capabilities.class); + Capabilities capabilities = parseUserCapabilities(user); return capabilities != null && capabilities.getSpreedCapability() != null && capabilities.getSpreedCapability().getFeatures() != null && @@ -140,7 +141,7 @@ public abstract class CapabilitiesUtil { public static boolean isReadStatusAvailable(@Nullable UserEntity user) { if (user != null && user.getCapabilities() != null) { try { - Capabilities capabilities = LoganSquare.parse(user.getCapabilities(), Capabilities.class); + Capabilities capabilities = parseUserCapabilities(user); if (capabilities != null && capabilities.getSpreedCapability() != null && capabilities.getSpreedCapability().getConfig() != null && @@ -158,7 +159,7 @@ public abstract class CapabilitiesUtil { public static boolean isReadStatusPrivate(@Nullable UserEntity user) { if (user != null && user.getCapabilities() != null) { try { - Capabilities capabilities = LoganSquare.parse(user.getCapabilities(), Capabilities.class); + Capabilities capabilities = parseUserCapabilities(user); if (capabilities != null && capabilities.getSpreedCapability() != null && capabilities.getSpreedCapability().getConfig() != null && @@ -178,7 +179,7 @@ public abstract class CapabilitiesUtil { public static boolean isUserStatusAvailable(@Nullable UserEntity user) { if (user != null && user.getCapabilities() != null) { try { - Capabilities capabilities = LoganSquare.parse(user.getCapabilities(), Capabilities.class); + Capabilities capabilities = parseUserCapabilities(user); if (capabilities.getUserStatusCapability() != null && capabilities.getUserStatusCapability().getEnabled() && capabilities.getUserStatusCapability().getSupportsEmoji()) { @@ -194,7 +195,7 @@ public abstract class CapabilitiesUtil { public static String getAttachmentFolder(@Nullable UserEntity user) { if (user != null && user.getCapabilities() != null) { try { - Capabilities capabilities = LoganSquare.parse(user.getCapabilities(), Capabilities.class); + Capabilities capabilities = parseUserCapabilities(user); if (capabilities != null && capabilities.getSpreedCapability() != null && capabilities.getSpreedCapability().getConfig() != null && @@ -213,9 +214,8 @@ public abstract class CapabilitiesUtil { public static String getServerName(@Nullable UserEntity user) { if (user != null && user.getCapabilities() != null) { - Capabilities capabilities; try { - capabilities = LoganSquare.parse(user.getCapabilities(), Capabilities.class); + Capabilities capabilities = parseUserCapabilities(user); if (capabilities != null && capabilities.getThemingCapability() != null) { return capabilities.getThemingCapability().getName(); } @@ -229,9 +229,8 @@ public abstract class CapabilitiesUtil { // TODO later avatar can also be checked via user fields, for now it is in Talk capability public static boolean isAvatarEndpointAvailable(@Nullable UserEntity user) { if (user != null && user.getCapabilities() != null) { - Capabilities capabilities; try { - capabilities = LoganSquare.parse(user.getCapabilities(), Capabilities.class); + Capabilities capabilities = parseUserCapabilities(user); return (capabilities != null && capabilities.getSpreedCapability() != null && capabilities.getSpreedCapability().getFeatures() != null && @@ -245,9 +244,8 @@ public abstract class CapabilitiesUtil { public static boolean canEditScopes(@Nullable UserEntity user) { if (user != null && user.getCapabilities() != null) { - Capabilities capabilities; try { - capabilities = LoganSquare.parse(user.getCapabilities(), Capabilities.class); + Capabilities capabilities = parseUserCapabilities(user); return (capabilities != null && capabilities.getProvisioningCapability() != null && capabilities.getProvisioningCapability().getAccountPropertyScopesVersion() != null && @@ -262,7 +260,7 @@ public abstract class CapabilitiesUtil { public static boolean isAbleToCall(@Nullable UserEntity user) { if (user != null && user.getCapabilities() != null) { try { - Capabilities capabilities = LoganSquare.parse(user.getCapabilities(), Capabilities.class); + Capabilities capabilities = parseUserCapabilities(user); if (capabilities != null && capabilities.getSpreedCapability() != null && capabilities.getSpreedCapability().getConfig() != null && @@ -281,4 +279,12 @@ public abstract class CapabilitiesUtil { } return false; } + + private static Capabilities parseUserCapabilities(@NonNull final UserEntity user) throws IOException { + return LoganSquare.parse(user.getCapabilities(), Capabilities.class); + } + + public static boolean isUnifiedSearchAvailable(@Nullable final UserEntity user) { + return hasSpreedFeatureCapability(user, "unified-search"); + } }