diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 41ae4ce7f..a098c8ff9 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -172,6 +172,10 @@ android:name=".shareditems.activities.SharedItemsActivity" android:theme="@style/AppTheme"/> + + 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/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..bc7e25d23 --- /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 = false +) : + 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..f2d96d83d 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; @@ -65,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; @@ -519,4 +519,12 @@ public interface NcApi { Observable getReactions(@Header("Authorization") String authorization, @Url String url, @Query("reaction") String reaction); + + @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/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 5fc0aa2a2..01fa2615a 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 @@ -1345,91 +1346,21 @@ class ChatController(args: Bundle) : } override fun onActivityResult(requestCode: Int, resultCode: Int, intent: Intent?) { - if (resultCode != RESULT_OK) { + 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()) @@ -1437,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() @@ -1452,6 +1409,79 @@ class ChatController(args: Bundle) : Log.e(javaClass.simpleName, "Something went wrong when trying to upload file", e) } } + 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? } } @@ -2279,6 +2309,7 @@ class ChatController(args: Bundle) : if (adapter != null) { adapter?.addToEnd(chatMessageList, false) } + scrollToRequestedMessageIfNeeded() } else { var chatMessage: ChatMessage @@ -2394,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) && @@ -2465,32 +2502,38 @@ 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) } } 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 +2545,13 @@ 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) + 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 +3137,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 dfb72b333..c2fe9e463 100644 --- a/app/src/main/java/com/nextcloud/talk/controllers/ConversationsListController.java +++ b/app/src/main/java/com/nextcloud/talk/controllers/ConversationsListController.java @@ -68,6 +68,9 @@ 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; @@ -78,11 +81,14 @@ 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; 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; @@ -94,6 +100,7 @@ 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; @@ -110,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; @@ -131,6 +139,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; @@ -138,13 +148,16 @@ 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; 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; @@ -207,6 +223,7 @@ public class ConversationsListController extends BaseController implements Searc private Conversation selectedConversation; private String textToPaste = ""; + private String selectedMessageId = null; private boolean forwardMessage; @@ -220,6 +237,9 @@ public class ConversationsListController extends BaseController implements Searc private HashMap userStatuses = new HashMap<>(); + private MessageSearchHelper searchHelper; + private Disposable searchViewDisposable; + public ConversationsListController(Bundle bundle) { super(); setHasOptionsMenu(true); @@ -306,6 +326,10 @@ public class ConversationsListController extends BaseController implements Searc return; } + if (CapabilitiesUtil.isUnifiedSearchAvailable(currentUser)) { + searchHelper = new MessageSearchHelper(unifiedSearchRepository); + } + credentials = ApiUtils.getCredentials(currentUser.getUsername(), currentUser.getToken()); if (getActivity() != null && getActivity() instanceof MainActivity) { loadUserAvatar(((MainActivity) getActivity()).binding.switchAccountButton); @@ -339,7 +363,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); } } } @@ -419,6 +454,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 +467,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); @@ -842,28 +882,76 @@ public class ConversationsListController extends BaseController implements Searc public void onDestroy() { super.onDestroy(); dispose(null); + searchViewDisposable.dispose(); } - @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 void onQueryTextChange(final String newText) { + if (!TextUtils.isEmpty(searchQuery)) { + final String filter = searchQuery; + searchQuery = ""; + performFilterAndSearch(filter); + } else if (adapter.hasNewFilter(newText)) { + performFilterAndSearch(newText); } - return true; } - @Override - public boolean onQueryTextSubmit(String query) { - return onQueryTextChange(query); + private void performFilterAndSearch(String filter) { + if (filter.length() >= SEARCH_MIN_CHARS) { + clearMessageSearchResults(); + adapter.setFilter(filter); + adapter.filterItems(); + if (CapabilitiesUtil.isUnifiedSearchAvailable(currentUser)) { + 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) { + if (swipeRefreshLayout != null) { + 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 protected String getTitle() { return getResources().getString(R.string.nc_app_product_name); @@ -871,38 +959,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(); - } + final AbstractFlexibleItem item = adapter.getItem(position); + 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()); } - } catch (ClassCastException e) { - Log.w(TAG, "failed to cast clicked item to ConversationItem. Most probably a heading was clicked. This is" + - " just ignored.", e); } return true; } + private void showConversationByToken(String conversationToken) { + for (AbstractFlexibleItem absItem : conversationItems) { + ConversationItem conversationItem = ((ConversationItem) absItem); + if (conversationItem.getModel().getToken().equals(conversationToken)) { + final Conversation conversation = conversationItem.getModel(); + 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; @@ -1085,6 +1194,10 @@ public class ConversationsListController extends BaseController implements Searc 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); @@ -1274,4 +1387,33 @@ 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<>(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)); + } + if (results.getHasMore()) { + adapterItems.add(LoadMoreResultsItem.INSTANCE); + } + adapter.addItems(0, adapterItems); + recyclerView.scrollToPosition(0); + } + } + if (swipeRefreshLayout != null) { + swipeRefreshLayout.setRefreshing(false); + } + } + + public void onMessageSearchError(@NonNull Throwable throwable) { + handleHttpExceptions(throwable); + if (swipeRefreshLayout != null) { + swipeRefreshLayout.setRefreshing(false); + } + } } 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..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 @@ -22,8 +22,11 @@ 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 com.nextcloud.talk.utils.database.user.CurrentUserProvider import dagger.Module import dagger.Provides @@ -33,4 +36,9 @@ class RepositoryModule { fun provideSharedItemsRepository(ncApi: NcApi): SharedItemsRepository { return SharedItemsRepositoryImpl(ncApi) } + + @Provides + fun provideUnifiedSearchRepository(ncApi: NcApi, userProvider: CurrentUserProvider): UnifiedSearchRepository { + return UnifiedSearchRepositoryImpl(ncApi, userProvider) + } } 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..ac2501522 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/messagesearch/MessageSearchActivity.kt @@ -0,0 +1,276 @@ +/* + * 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.content.Intent +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.database.user.CurrentUserProvider +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 + + @Inject + lateinit var userProvider: CurrentUserProvider + + 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 = userProvider.currentUser!! + val roomToken = intent.getStringExtra(BundleKeys.KEY_ROOM_TOKEN)!! + viewModel.initialize(roomToken) + setupStateObserver() + + binding.swipeRefreshLayout.setOnRefreshListener { + viewModel.refresh(searchView.query?.toString()) + } + } + + 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.InitialState -> showInitial() + MessageSearchViewModel.EmptyState -> showEmpty() + is MessageSearchViewModel.LoadedState -> showLoaded(state) + MessageSearchViewModel.LoadingState -> showLoading() + MessageSearchViewModel.ErrorState -> showError() + is MessageSearchViewModel.FinishedState -> onFinish() + } + } + } + + private fun showError() { + displayLoading(false) + Toast.makeText(this, "Error while searching", Toast.LENGTH_SHORT).show() + } + + private fun showLoading() { + 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) + } + + 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) + when (item?.itemViewType) { + LoadMoreResultsItem.VIEW_TYPE -> { + viewModel.loadMore() + } + MessageResultItem.VIEW_TYPE -> { + val messageItem = item as MessageResultItem + viewModel.selectMessage(messageItem.messageEntry) + } + } + return false + } + }) + } + + 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() + } + } + + private fun showInitial() { + displayLoading(false) + binding.messageSearchRecycler.visibility = View.GONE + 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 = getString(R.string.message_search_begin_empty) + 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.message_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() + } + + companion object { + const val RESULT_KEY_MESSAGE_ID = "MessageSearchActivity.result.message" + } +} diff --git a/app/src/main/java/com/nextcloud/talk/messagesearch/MessageSearchHelper.kt b/app/src/main/java/com/nextcloud/talk/messagesearch/MessageSearchHelper.kt new file mode 100644 index 000000000..e303f1d2d --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/messagesearch/MessageSearchHelper.kt @@ -0,0 +1,113 @@ +/* + * 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.util.Log +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 unifiedSearchRepository: UnifiedSearchRepository, + private val fromRoom: String? = null +) { + + 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 { + resetCachedData() + 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 { + disposeIfPossible() + return searchCall(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 searchCall( + search: String, + cursor: Int + ): Observable> { + return when { + fromRoom != null -> { + unifiedSearchRepository.searchInRoom( + roomToken = fromRoom, + searchTerm = search, + cursor = cursor + ) + } + else -> { + unifiedSearchRepository.searchMessages( + searchTerm = search, + cursor = cursor + ) + } + } + } + + 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/messagesearch/MessageSearchViewModel.kt b/app/src/main/java/com/nextcloud/talk/messagesearch/MessageSearchViewModel.kt new file mode 100644 index 000000000..1864070e8 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/messagesearch/MessageSearchViewModel.kt @@ -0,0 +1,119 @@ +/* + * 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.domain.SearchMessageEntry +import com.nextcloud.talk.repositories.unifiedsearch.UnifiedSearchRepository +import io.reactivex.android.schedulers.AndroidSchedulers +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() + class FinishedState(val selectedMessageId: String) : ViewState() + + private lateinit var messageSearchHelper: MessageSearchHelper + + private val _state: MutableLiveData = MutableLiveData(InitialState) + val state: LiveData + get() = _state + + fun initialize(roomToken: String) { + messageSearchHelper = MessageSearchHelper(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 + } + + fun refresh(query: String?) { + 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 + } +} 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"); + } } 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..a24e86bdd --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/repositories/unifiedsearch/UnifiedSearchRepository.kt @@ -0,0 +1,29 @@ +package com.nextcloud.talk.repositories.unifiedsearch + +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( + searchTerm: String, + cursor: Int = 0, + limit: Int = DEFAULT_PAGE_SIZE + ): Observable> + + fun searchInRoom( + 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 new file mode 100644 index 000000000..39623b3a2 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/repositories/unifiedsearch/UnifiedSearchRepositoryImpl.kt @@ -0,0 +1,105 @@ +/* + * 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 com.nextcloud.talk.utils.database.user.CurrentUserProvider +import io.reactivex.Observable + +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( + searchTerm: String, + cursor: Int, + limit: Int + ): Observable> { + val apiObservable = api.performUnifiedSearch( + credentials, + ApiUtils.getUrlForUnifiedSearch(userEntity.baseUrl, PROVIDER_TALK_MESSAGE), + searchTerm, + null, + limit, + cursor + ) + return apiObservable.map { mapToMessageResults(it.ocs?.data!!, searchTerm, limit) } + } + + override fun searchInRoom( + roomToken: String, + searchTerm: String, + cursor: Int, + limit: Int + ): Observable> { + val apiObservable = api.performUnifiedSearch( + credentials, + 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" + + 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/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/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" } 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/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 + } + } +} 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..f7c373f13 --- /dev/null +++ b/app/src/main/res/layout/activity_message_search.xml @@ -0,0 +1,72 @@ + + + + + + + + + + + + + + + + + + + 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/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/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 4da9a3695..7191fba43 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 @@ -521,6 +521,13 @@ Voice Other + + Messages + Load more results + Search… + Start typing to search… + No search results + Attachments All diff --git a/app/src/test/java/com/nextcloud/talk/messagesearch/MessageSearchHelperTest.kt b/app/src/test/java/com/nextcloud/talk/messagesearch/MessageSearchHelperTest.kt new file mode 100644 index 000000000..fe760a7e1 --- /dev/null +++ b/app/src/test/java/com/nextcloud/talk/messagesearch/MessageSearchHelperTest.kt @@ -0,0 +1,141 @@ +/* + * 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 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.MockitoAnnotations + +class MessageSearchHelperTest { + + 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(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(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(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(repository) + + repeat(5) { + val observable = sut.startMessageSearch("foo") + val expected = MessageSearchHelper.MessageSearchResults(entries, false) + testCall(observable, expected) + } + } + + @Test + fun loadMore_noPreviousResults() { + val sut = MessageSearchHelper(repository) + Assert.assertEquals(null, sut.loadMore()) + } + + @Test + fun loadMore_previousResults_sameSearch() { + val sut = MessageSearchHelper(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..33d34f772 --- /dev/null +++ b/app/src/test/java/com/nextcloud/talk/test/fakes/FakeUnifiedSearchRepository.kt @@ -0,0 +1,51 @@ +/* + * 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.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( + searchTerm: String, + cursor: Int, + limit: Int + ): Observable> { + lastRequestedCursor = cursor + return Observable.just(response) + } + + override fun searchInRoom( + roomToken: String, + searchTerm: String, + cursor: Int, + limit: Int + ): Observable> { + lastRequestedCursor = cursor + return Observable.just(response) + } +}