فهرست منبع

Merge pull request #2028 from nextcloud/feat/1047/message-search

Message search
Andy Scherzinger 3 سال پیش
والد
کامیت
5d482e4857
38فایلهای تغییر یافته به همراه2037 افزوده شده و 181 حذف شده
  1. 4 0
      app/src/main/AndroidManifest.xml
  2. 8 0
      app/src/main/java/com/nextcloud/talk/adapters/items/ConversationItem.java
  3. 79 0
      app/src/main/java/com/nextcloud/talk/adapters/items/LoadMoreResultsItem.kt
  4. 115 0
      app/src/main/java/com/nextcloud/talk/adapters/items/MessageResultItem.kt
  5. 36 0
      app/src/main/java/com/nextcloud/talk/adapters/items/MessagesTextHeaderItem.kt
  6. 9 1
      app/src/main/java/com/nextcloud/talk/api/NcApi.java
  7. 0 1
      app/src/main/java/com/nextcloud/talk/application/NextcloudTalkApplication.kt
  8. 140 89
      app/src/main/java/com/nextcloud/talk/controllers/ChatController.kt
  9. 185 43
      app/src/main/java/com/nextcloud/talk/controllers/ConversationsListController.java
  10. 8 0
      app/src/main/java/com/nextcloud/talk/dagger/modules/RepositoryModule.kt
  11. 6 0
      app/src/main/java/com/nextcloud/talk/dagger/modules/ViewModelModule.kt
  12. 276 0
      app/src/main/java/com/nextcloud/talk/messagesearch/MessageSearchActivity.kt
  13. 113 0
      app/src/main/java/com/nextcloud/talk/messagesearch/MessageSearchHelper.kt
  14. 119 0
      app/src/main/java/com/nextcloud/talk/messagesearch/MessageSearchViewModel.kt
  15. 22 16
      app/src/main/java/com/nextcloud/talk/models/database/CapabilitiesUtil.java
  16. 31 0
      app/src/main/java/com/nextcloud/talk/models/domain/SearchMessageEntry.kt
  17. 48 0
      app/src/main/java/com/nextcloud/talk/models/json/unifiedsearch/UnifiedSearchEntry.kt
  18. 38 0
      app/src/main/java/com/nextcloud/talk/models/json/unifiedsearch/UnifiedSearchOCS.kt
  19. 35 0
      app/src/main/java/com/nextcloud/talk/models/json/unifiedsearch/UnifiedSearchOverall.kt
  20. 43 0
      app/src/main/java/com/nextcloud/talk/models/json/unifiedsearch/UnifiedSearchResponseData.kt
  21. 29 0
      app/src/main/java/com/nextcloud/talk/repositories/unifiedsearch/UnifiedSearchRepository.kt
  22. 105 0
      app/src/main/java/com/nextcloud/talk/repositories/unifiedsearch/UnifiedSearchRepositoryImpl.kt
  23. 6 1
      app/src/main/java/com/nextcloud/talk/utils/ApiUtils.java
  24. 25 0
      app/src/main/java/com/nextcloud/talk/utils/DisplayUtils.java
  25. 1 0
      app/src/main/java/com/nextcloud/talk/utils/bundle/BundleKeys.kt
  26. 27 0
      app/src/main/java/com/nextcloud/talk/utils/database/user/CurrentUserProvider.kt
  27. 16 19
      app/src/main/java/com/nextcloud/talk/utils/database/user/UserModule.kt
  28. 2 1
      app/src/main/java/com/nextcloud/talk/utils/database/user/UserUtils.java
  29. 48 0
      app/src/main/java/com/nextcloud/talk/utils/rx/SearchViewObservable.kt
  30. 72 0
      app/src/main/res/layout/activity_message_search.xml
  31. 3 0
      app/src/main/res/layout/empty_list.xml
  32. 62 0
      app/src/main/res/layout/rv_item_load_more.xml
  33. 80 0
      app/src/main/res/layout/rv_item_search_message.xml
  34. 9 2
      app/src/main/res/menu/menu_conversation.xml
  35. 30 0
      app/src/main/res/menu/menu_search.xml
  36. 15 8
      app/src/main/res/values/strings.xml
  37. 141 0
      app/src/test/java/com/nextcloud/talk/messagesearch/MessageSearchHelperTest.kt
  38. 51 0
      app/src/test/java/com/nextcloud/talk/test/fakes/FakeUnifiedSearchRepository.kt

+ 4 - 0
app/src/main/AndroidManifest.xml

@@ -172,6 +172,10 @@
             android:name=".shareditems.activities.SharedItemsActivity"
             android:theme="@style/AppTheme"/>
 
+        <activity
+            android:name=".messagesearch.MessageSearchActivity"
+            android:theme="@style/AppTheme" />
+
         <receiver android:name=".receivers.PackageReplacedReceiver">
             <intent-filter>
                 <action android:name="android.intent.action.MY_PACKAGE_REPLACED" />

+ 8 - 0
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<ConversationItem.ConversationItemViewHolder> implements
     ISectionable<ConversationItem.ConversationItemViewHolder, GenericTextHeaderItem>, IFilterable<String> {
 
+    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<ConversationItem.Conv
     private GenericTextHeaderItem header;
     private final Status status;
 
+
     public ConversationItem(Conversation conversation, UserEntity userEntity, Context activityContext, Status status) {
         this.conversation = conversation;
         this.userEntity = userEntity;
@@ -114,6 +117,11 @@ public class ConversationItem extends AbstractFlexibleItem<ConversationItem.Conv
         return R.layout.rv_item_conversation_with_last_message;
     }
 
+    @Override
+    public int getItemViewType() {
+        return VIEW_TYPE;
+    }
+
     @Override
     public ConversationItemViewHolder createViewHolder(View view, FlexibleAdapter<IFlexible> adapter) {
         return new ConversationItemViewHolder(view, adapter);

+ 79 - 0
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 <https://www.gnu.org/licenses/>.
+ */
+
+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<LoadMoreResultsItem.ViewHolder>(),
+    IFilterable<String> {
+
+    // 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<IFlexible<RecyclerView.ViewHolder>>
+    ): ViewHolder = ViewHolder(view, adapter)
+
+    override fun bindViewHolder(
+        adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>,
+        holder: ViewHolder,
+        position: Int,
+        payloads: MutableList<Any>?
+    ) {
+        // 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
+    }
+}

+ 115 - 0
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 <https://www.gnu.org/licenses/>.
+ */
+
+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<MessageResultItem.ViewHolder>(),
+    IFilterable<String>,
+    ISectionable<MessageResultItem.ViewHolder, GenericTextHeaderItem> {
+
+    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<IFlexible<RecyclerView.ViewHolder>>
+    ): ViewHolder = ViewHolder(view, adapter)
+
+    override fun bindViewHolder(
+        adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>,
+        holder: ViewHolder,
+        position: Int,
+        payloads: MutableList<Any>?
+    ) {
+        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
+    }
+}

+ 36 - 0
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 <https://www.gnu.org/licenses/>.
+ */
+
+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
+}

+ 9 - 1
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<ReactionsOverall> getReactions(@Header("Authorization") String authorization,
                                               @Url String url,
                                               @Query("reaction") String reaction);
+
+    @GET
+    Observable<UnifiedSearchOverall> performUnifiedSearch(@Header("Authorization") String authorization,
+                                                          @Url String url,
+                                                          @Query("term") String term,
+                                                          @Query("from") String fromUrl,
+                                                          @Query("limit") Integer limit,
+                                                          @Query("cursor") Integer cursor);
 }

+ 0 - 1
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()
     }

+ 140 - 89
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
+            }
+            R.id.conversation_search -> {
+                startMessageSearch()
+                true
             }
-            else -> return super.onOptionsItemSelected(item)
+            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<ChatMessage>): List<ChatMessage> {
         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

+ 185 - 43
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<String, Status> 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);
+        }
+    }
+
+    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();
         }
-        return true;
     }
 
-    @Override
-    public boolean onQueryTextSubmit(String query) {
-        return onQueryTextChange(query);
+    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<MessageSearchHelper.MessageSearchResults> 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,36 +959,57 @@ public class ConversationsListController extends BaseController implements Searc
 
     @Override
     public boolean onItemClick(View view, int position) {
-        try {
-            selectedConversation = ((ConversationItem) Objects.requireNonNull(adapter.getItem(position))).getModel();
+        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());
+            }
+        }
+        return true;
+    }
 
-            if (selectedConversation != null && getActivity() != null) {
-                boolean hasChatPermission =
-                    new AttendeePermissionsUtil(selectedConversation.getPermissions()).hasChatPermission(currentUser);
+    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);
+            }
+        }
+    }
 
-                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();
-                    }
+    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 {
-                    openConversation();
+                    Toast.makeText(context, R.string.send_to_forbidden, Toast.LENGTH_LONG).show();
                 }
+            } else if (forwardMessage) {
+                if (hasChatPermission && !isReadOnlyConversation(selectedConversation)) {
+                    openConversation(bundle.getString(BundleKeys.INSTANCE.getKEY_FORWARD_MSG_TEXT()));
+                    forwardMessage = false;
+                } else {
+                    Toast.makeText(context, R.string.send_to_forbidden, Toast.LENGTH_LONG).show();
+                }
+            } else {
+                openConversation();
             }
-        } catch (ClassCastException e) {
-            Log.w(TAG, "failed to cast clicked item to ConversationItem. Most probably a heading was clicked. This is" +
-                " just ignored.", e);
         }
-        return true;
     }
 
     private Boolean isReadOnlyConversation(Conversation conversation) {
@@ -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<SearchMessageEntry> entries = results.getMessages();
+            if (entries.size() > 0) {
+                List<AbstractFlexibleItem> 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);
+        }
+    }
 }

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

+ 6 - 0
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
 }

+ 276 - 0
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 <https://www.gnu.org/licenses/>.
+ */
+
+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<AbstractFlexibleItem<*>>? = 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<AbstractFlexibleItem<out FlexibleViewHolder>>) {
+        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"
+    }
+}

+ 113 - 0
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 <https://www.gnu.org/licenses/>.
+ */
+
+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<SearchMessageEntry>, val hasMore: Boolean)
+
+    private var unifiedSearchDisposable: Disposable? = null
+    private var previousSearch: String? = null
+    private var previousCursor: Int = 0
+    private var previousResults: List<SearchMessageEntry> = emptyList()
+
+    fun startMessageSearch(search: String): Observable<MessageSearchResults> {
+        resetCachedData()
+        return doSearch(search)
+    }
+
+    fun loadMore(): Observable<MessageSearchResults>? {
+        previousSearch?.let {
+            return doSearch(it, previousCursor)
+        }
+        return null
+    }
+
+    fun cancelSearch() {
+        disposeIfPossible()
+    }
+
+    private fun doSearch(search: String, cursor: Int = 0): Observable<MessageSearchResults> {
+        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<UnifiedSearchRepository.UnifiedSearchResults<SearchMessageEntry>> {
+        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
+    }
+}

+ 119 - 0
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 <https://www.gnu.org/licenses/>.
+ */
+
+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<SearchMessageEntry>, val hasMore: Boolean) : ViewState()
+    class FinishedState(val selectedMessageId: String) : ViewState()
+
+    private lateinit var messageSearchHelper: MessageSearchHelper
+
+    private val _state: MutableLiveData<ViewState> = MutableLiveData(InitialState)
+    val state: LiveData<ViewState>
+        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
+    }
+}

+ 22 - 16
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");
+    }
 }

+ 31 - 0
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 <https://www.gnu.org/licenses/>.
+ */
+
+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?
+)

+ 48 - 0
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 <https://www.gnu.org/licenses/>.
+ */
+
+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<String, String>?,
+) : Parcelable {
+    constructor() : this(null, null, null, null, null, null, null)
+}

+ 38 - 0
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 <dev@mhibbe.de>
+ *
+ *   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 <http://www.gnu.org/licenses/>.
+ */
+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)
+}

+ 35 - 0
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 <dev@mhibbe.de>
+ *
+ *   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 <http://www.gnu.org/licenses/>.
+ */
+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)
+}

+ 43 - 0
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 <https://www.gnu.org/licenses/>.
+ */
+
+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<UnifiedSearchEntry>?,
+    @JsonField(name = ["cursor"])
+    var cursor: Int?
+) : Parcelable {
+    // empty constructor needed for JsonObject
+    constructor() : this(null, null, null, null)
+}

+ 29 - 0
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<T>(
+        val cursor: Int,
+        val hasMore: Boolean,
+        val entries: List<T>
+    )
+
+    fun searchMessages(
+        searchTerm: String,
+        cursor: Int = 0,
+        limit: Int = DEFAULT_PAGE_SIZE
+    ): Observable<UnifiedSearchResults<SearchMessageEntry>>
+
+    fun searchInRoom(
+        roomToken: String,
+        searchTerm: String,
+        cursor: Int = 0,
+        limit: Int = DEFAULT_PAGE_SIZE
+    ): Observable<UnifiedSearchResults<SearchMessageEntry>>
+
+    companion object {
+        private const val DEFAULT_PAGE_SIZE = 5
+    }
+}

+ 105 - 0
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 <https://www.gnu.org/licenses/>.
+ */
+
+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<UnifiedSearchRepository.UnifiedSearchResults<SearchMessageEntry>> {
+        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<UnifiedSearchRepository.UnifiedSearchResults<SearchMessageEntry>> {
+        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<SearchMessageEntry> {
+            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
+            )
+        }
+    }
+}

+ 6 - 1
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";
+    }
 }

+ 25 - 0
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) {

+ 1 - 0
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"
 }

+ 27 - 0
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 <https://www.gnu.org/licenses/>.
+ */
+package com.nextcloud.talk.utils.database.user
+
+import com.nextcloud.talk.models.database.UserEntity
+
+interface CurrentUserProvider {
+    val currentUser: UserEntity?
+}

+ 16 - 19
app/src/main/java/com/nextcloud/talk/utils/database/user/UserModule.java → 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 <http://www.gnu.org/licenses/>.
  */
-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<Persistable> dataStore) {
-        return new UserUtils(dataStore);
+    companion object {
+        @Provides
+        fun provideUserUtils(dataStore: ReactiveEntityStore<Persistable?>?): UserUtils {
+            return UserUtils(dataStore)
+        }
     }
 }

+ 2 - 1
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<Persistable> dataStore;
 
     UserUtils(ReactiveEntityStore<Persistable> 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)))

+ 48 - 0
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 <https://www.gnu.org/licenses/>.
+ */
+
+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<String> {
+            val subject: PublishSubject<String> = 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
+        }
+    }
+}

+ 72 - 0
app/src/main/res/layout/activity_message_search.xml

@@ -0,0 +1,72 @@
+<?xml version="1.0" encoding="utf-8"?><!--
+  ~ Nextcloud Talk application
+  ~
+  ~ @author Álvaro Brey
+  ~ Copyright (C) 2022 Álvaro Brey <alvaro.brey@nextcloud.com>
+  ~ 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 <http://www.gnu.org/licenses/>.
+  -->
+<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto"
+    xmlns:tools="http://schemas.android.com/tools"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent"
+    android:background="@color/bg_default"
+    tools:ignore="Overdraw"
+    tools:context=".messagesearch.MessageSearchActivity">
+
+    <com.google.android.material.appbar.AppBarLayout
+        android:id="@+id/message_search_appbar"
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content">
+
+        <com.google.android.material.appbar.MaterialToolbar
+            android:id="@+id/message_search_toolbar"
+            android:layout_width="match_parent"
+            android:layout_height="?attr/actionBarSize"
+            android:background="@color/appbar"
+            android:theme="?attr/actionBarPopupTheme"
+            app:layout_scrollFlags="scroll|enterAlways"
+            app:navigationIconTint="@color/fontAppbar"
+            app:popupTheme="@style/appActionBarPopupMenu"
+            app:titleTextColor="@color/fontAppbar"
+            tools:title="@string/nc_app_product_name"></com.google.android.material.appbar.MaterialToolbar>
+
+    </com.google.android.material.appbar.AppBarLayout>
+
+    <include
+        android:id="@+id/emptyContainer"
+        layout="@layout/empty_list"
+        android:visibility="gone"
+        tools:visibility="visible" />
+
+
+    <androidx.swiperefreshlayout.widget.SwipeRefreshLayout
+        android:id="@+id/swipe_refresh_layout"
+        android:layout_width="match_parent"
+        android:layout_height="match_parent"
+        app:layout_behavior="com.nextcloud.talk.utils.FABAwareScrollingViewBehavior">
+
+        <androidx.recyclerview.widget.RecyclerView
+            android:id="@+id/message_search_recycler"
+            android:layout_width="match_parent"
+            android:layout_height="match_parent"
+            app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
+            app:layout_behavior="@string/appbar_scrolling_view_behavior"
+            tools:listitem="@layout/rv_item_search_message" />
+
+    </androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
+
+</androidx.coordinatorlayout.widget.CoordinatorLayout>

+ 3 - 0
app/src/main/res/layout/empty_list.xml

@@ -20,6 +20,7 @@
   License along with this program. If not, see <http://www.gnu.org/licenses/>.
 -->
 <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:tools="http://schemas.android.com/tools"
     android:id="@+id/empty_list_view"
     android:layout_width="match_parent"
     android:layout_height="match_parent"
@@ -60,5 +61,7 @@
         android:paddingTop="@dimen/standard_half_padding"
         android:paddingBottom="@dimen/standard_half_padding"
         android:text=""
+        tools:visibility="visible"
+        tools:text="Empty list view text"
         android:visibility="gone" />
 </LinearLayout>

+ 62 - 0
app/src/main/res/layout/rv_item_load_more.xml

@@ -0,0 +1,62 @@
+<?xml version="1.0" encoding="utf-8"?><!--
+  ~ Nextcloud Talk application
+  ~
+  ~ @author Mario Danic
+  ~ @author Andy Scherzinger
+  ~ Copyright (C) 2021 Andy Scherzinger <info@andy-scherzinger.de>
+  ~ Copyright (C) 2017-2018 Mario Danic <mario@lovelyhq.com>
+  ~
+  ~ 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 <http://www.gnu.org/licenses/>.
+  ~
+  ~
+  ~
+  ~ Adapted from https://github.com/stfalcon-studio/ChatKit/blob/master/chatkit/src/main/res/layout/item_dialog.xml
+  -->
+
+<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto"
+    xmlns:tools="http://schemas.android.com/tools"
+    android:clickable="true"
+    android:focusable="true"
+    android:background="?android:attr/selectableItemBackground"
+    android:layout_width="match_parent"
+    android:layout_height="wrap_content"
+    android:layout_margin="@dimen/double_margin_between_elements"
+    tools:background="@color/white">
+
+    <Space
+        android:id="@+id/load_more_spacer"
+        android:layout_width="@dimen/small_item_height"
+        android:layout_height="@dimen/small_item_height"
+        android:importantForAccessibility="no"
+        app:layout_constraintStart_toStartOf="parent"
+        app:layout_constraintTop_toTopOf="parent"
+        app:roundAsCircle="true" />
+
+    <TextView
+        android:layout_width="0dp"
+        android:layout_height="wrap_content"
+        android:layout_marginHorizontal="@dimen/double_margin_between_elements"
+        android:includeFontPadding="false"
+        android:maxLines="1"
+        android:textColor="@color/textColorMaxContrast"
+        android:textSize="@dimen/two_line_primary_text_size"
+        android:text="@string/load_more_results"
+        app:layout_constraintBottom_toBottomOf="parent"
+        app:layout_constraintEnd_toEndOf="parent"
+        app:layout_constraintStart_toEndOf="@+id/load_more_spacer"
+        app:layout_constraintTop_toTopOf="parent" />
+
+
+</androidx.constraintlayout.widget.ConstraintLayout>

+ 80 - 0
app/src/main/res/layout/rv_item_search_message.xml

@@ -0,0 +1,80 @@
+<?xml version="1.0" encoding="utf-8"?><!--
+  ~ Nextcloud Talk application
+  ~
+  ~ @author Mario Danic
+  ~ @author Andy Scherzinger
+  ~ Copyright (C) 2021 Andy Scherzinger <info@andy-scherzinger.de>
+  ~ Copyright (C) 2017-2018 Mario Danic <mario@lovelyhq.com>
+  ~
+  ~ 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 <http://www.gnu.org/licenses/>.
+  ~
+  ~
+  ~
+  ~ Adapted from https://github.com/stfalcon-studio/ChatKit/blob/master/chatkit/src/main/res/layout/item_dialog.xml
+  -->
+
+<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto"
+    xmlns:tools="http://schemas.android.com/tools"
+    android:clickable="true"
+    android:focusable="true"
+    android:background="?attr/selectableItemBackground"
+    android:layout_width="match_parent"
+    android:layout_height="wrap_content"
+    android:layout_margin="@dimen/double_margin_between_elements"
+    tools:background="@color/white">
+
+    <com.facebook.drawee.view.SimpleDraweeView
+        android:id="@+id/thumbnail"
+        android:layout_width="@dimen/small_item_height"
+        android:layout_height="@dimen/small_item_height"
+        android:importantForAccessibility="no"
+        app:layout_constraintStart_toStartOf="parent"
+        app:layout_constraintTop_toTopOf="parent"
+        app:roundAsCircle="true" />
+
+    <androidx.emoji.widget.EmojiTextView
+        android:id="@+id/conversation_title"
+        android:layout_width="0dp"
+        android:layout_height="wrap_content"
+        android:layout_marginHorizontal="@dimen/double_margin_between_elements"
+        android:layout_marginTop="2dp"
+        android:ellipsize="end"
+        android:includeFontPadding="false"
+        android:maxLines="1"
+        android:textColor="@color/conversation_item_header"
+        android:textSize="@dimen/two_line_primary_text_size"
+        app:layout_constraintEnd_toEndOf="parent"
+        app:layout_constraintStart_toEndOf="@+id/thumbnail"
+        app:layout_constraintTop_toTopOf="parent"
+        tools:text="Message title goes here" />
+
+    <androidx.emoji.widget.EmojiTextView
+        android:id="@+id/message_excerpt"
+        android:layout_width="0dp"
+        android:layout_height="wrap_content"
+        android:layout_marginTop="6dp"
+        android:ellipsize="end"
+        android:gravity="start|top"
+        android:lines="1"
+        android:singleLine="true"
+        android:textColor="@color/textColorMaxContrast"
+        android:textSize="14sp"
+        app:layout_constraintEnd_toEndOf="@id/conversation_title"
+        app:layout_constraintStart_toStartOf="@+id/conversation_title"
+        app:layout_constraintTop_toBottomOf="@id/conversation_title"
+        tools:text="...this is a message result from unified search, which includes ellipses..." />
+
+
+</androidx.constraintlayout.widget.ConstraintLayout>

+ 9 - 2
app/src/main/res/menu/menu_conversation.xml

@@ -35,15 +35,22 @@
         android:title="@string/nc_conversation_menu_video_call"
         app:showAsAction="ifRoom" />
 
+    <item
+        android:id="@+id/conversation_search"
+        android:icon="@drawable/ic_search_white_24dp"
+        android:orderInCategory="2"
+        android:title="@string/nc_search"
+        app:showAsAction="ifRoom" />
+
     <item
         android:id="@+id/conversation_info"
-        android:orderInCategory="1"
+        android:orderInCategory="3"
         android:title="@string/nc_conversation_menu_conversation_info"
         app:showAsAction="never" />
 
     <item
         android:id="@+id/shared_items"
-        android:orderInCategory="1"
+        android:orderInCategory="4"
         android:title="@string/nc_shared_items"
         app:showAsAction="never" />
 </menu>

+ 30 - 0
app/src/main/res/menu/menu_search.xml

@@ -0,0 +1,30 @@
+<?xml version="1.0" encoding="utf-8"?><!--
+  ~ 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 <https://www.gnu.org/licenses/>.
+  -->
+
+<menu xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto">
+    <item
+        android:id="@+id/action_search"
+        android:icon="@drawable/ic_search_white_24dp"
+        android:title="@string/nc_search"
+        app:actionViewClass="androidx.appcompat.widget.SearchView"
+        app:showAsAction="always|collapseActionView" />
+</menu>

+ 15 - 8
app/src/main/res/values/strings.xml

@@ -273,14 +273,14 @@
     <string name="dnd">Do not disturb</string>
     <string name="away">Away</string>
     <string name="invisible">Invisible</string>
-    <string translatable="false" name="divider">—</string>
-    <string translatable="false" name="default_emoji">😃</string>
-    <string translatable="false" name="emoji_thumbsUp">👍</string>
-    <string translatable="false" name="emoji_thumbsDown">👎</string>
-    <string translatable="false" name="emoji_heart">❤️</string>
-    <string translatable="false" name="emoji_confused">😯</string>
-    <string translatable="false" name="emoji_sad">😢</string>
-    <string translatable="false" name="emoji_more">More emojis</string>
+    <string name="divider" translatable="false">—</string>
+    <string name="default_emoji" translatable="false">😃</string>
+    <string name="emoji_thumbsUp" translatable="false">👍</string>
+    <string name="emoji_thumbsDown" translatable="false">👎</string>
+    <string name="emoji_heart" translatable="false">❤️</string>
+    <string name="emoji_confused" translatable="false">😯</string>
+    <string name="emoji_sad" translatable="false">😢</string>
+    <string name="emoji_more" translatable="false">More emojis</string>
     <string name="dontClear">Don\'t clear</string>
     <string name="today">Today</string>
     <string name="thirtyMinutes">30 minutes</string>
@@ -521,6 +521,13 @@
     <string name="shared_items_voice">Voice</string>
     <string name="shared_items_other">Other</string>
 
+    <!-- Message search -->
+    <string name="messages">Messages</string>
+    <string name="load_more_results">Load more results</string>
+    <string name="message_search_hint">Search…</string>
+    <string name="message_search_begin_typing">Start typing to search…</string>
+    <string name="message_search_begin_empty">No search results</string>
+
     <string name="title_attachments">Attachments</string>
 
     <string name="reactions_tab_all">All</string>

+ 141 - 0
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 <https://www.gnu.org/licenses/>.
+ */
+
+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<MessageSearchHelper.MessageSearchResults>,
+        expectedResult: MessageSearchHelper.MessageSearchResults
+    ) {
+        val testObserver = searchCall.test()
+        testObserver.assertComplete()
+        testObserver.assertValueCount(1)
+        testObserver.assertValue(expectedResult)
+        testObserver.dispose()
+    }
+}

+ 51 - 0
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 <https://www.gnu.org/licenses/>.
+ */
+
+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<SearchMessageEntry>
+    var lastRequestedCursor = -1
+
+    override fun searchMessages(
+        searchTerm: String,
+        cursor: Int,
+        limit: Int
+    ): Observable<UnifiedSearchRepository.UnifiedSearchResults<SearchMessageEntry>> {
+        lastRequestedCursor = cursor
+        return Observable.just(response)
+    }
+
+    override fun searchInRoom(
+        roomToken: String,
+        searchTerm: String,
+        cursor: Int,
+        limit: Int
+    ): Observable<UnifiedSearchRepository.UnifiedSearchResults<SearchMessageEntry>> {
+        lastRequestedCursor = cursor
+        return Observable.just(response)
+    }
+}