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)
+ }
+}