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

Message search
This commit is contained in:
Andy Scherzinger 2022-06-02 13:18:41 +02:00 committed by GitHub
commit 5d482e4857
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
38 changed files with 2041 additions and 185 deletions

View File

@ -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" />

View File

@ -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);

View File

@ -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
}
}

View File

@ -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
}
}

View File

@ -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
}

View File

@ -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);
}

View File

@ -213,7 +213,6 @@ class NextcloudTalkApplication : MultiDexApplication(), LifecycleObserver {
.contextModule(ContextModule(applicationContext))
.databaseModule(DatabaseModule())
.restModule(RestModule(applicationContext))
.userModule(UserModule())
.arbitraryStorageModule(ArbitraryStorageModule())
.build()
}

View File

@ -130,6 +130,7 @@ import com.nextcloud.talk.events.UserMentionClickEvent
import com.nextcloud.talk.events.WebSocketCommunicationEvent
import com.nextcloud.talk.jobs.DownloadFileToCacheWorker
import com.nextcloud.talk.jobs.UploadAndShareFilesWorker
import com.nextcloud.talk.messagesearch.MessageSearchActivity
import com.nextcloud.talk.models.database.CapabilitiesUtil
import com.nextcloud.talk.models.database.UserEntity
import com.nextcloud.talk.models.json.chat.ChatMessage
@ -1345,91 +1346,21 @@ class ChatController(args: Bundle) :
}
override fun onActivityResult(requestCode: Int, resultCode: Int, intent: Intent?) {
if (resultCode != RESULT_OK) {
if (resultCode != RESULT_OK && (requestCode != REQUEST_CODE_MESSAGE_SEARCH)) {
Log.e(TAG, "resultCode for received intent was != ok")
return
}
if (requestCode == REQUEST_CODE_CHOOSE_FILE) {
try {
checkNotNull(intent)
filesToUpload.clear()
intent.clipData?.let {
for (index in 0 until it.itemCount) {
filesToUpload.add(it.getItemAt(index).uri.toString())
}
} ?: run {
checkNotNull(intent.data)
intent.data.let {
filesToUpload.add(intent.data.toString())
}
}
require(filesToUpload.isNotEmpty())
val filenamesWithLinebreaks = StringBuilder("\n")
for (file in filesToUpload) {
val filename = UriUtils.getFileName(Uri.parse(file), context)
filenamesWithLinebreaks.append(filename).append("\n")
}
val confirmationQuestion = when (filesToUpload.size) {
1 -> context?.resources?.getString(R.string.nc_upload_confirm_send_single)?.let {
String.format(it, title)
}
else -> context?.resources?.getString(R.string.nc_upload_confirm_send_multiple)?.let {
String.format(it, title)
}
}
LovelyStandardDialog(activity)
.setPositiveButtonColorRes(R.color.nc_darkGreen)
.setTitle(confirmationQuestion)
.setMessage(filenamesWithLinebreaks.toString())
.setPositiveButton(R.string.nc_yes) { v ->
if (UploadAndShareFilesWorker.isStoragePermissionGranted(context!!)) {
uploadFiles(filesToUpload, false)
} else {
UploadAndShareFilesWorker.requestStoragePermission(this)
}
}
.setNegativeButton(R.string.nc_no) {
// unused atm
}
.show()
} catch (e: IllegalStateException) {
Toast.makeText(context, context?.resources?.getString(R.string.nc_upload_failed), Toast.LENGTH_LONG)
.show()
Log.e(javaClass.simpleName, "Something went wrong when trying to upload file", e)
} catch (e: IllegalArgumentException) {
Toast.makeText(context, context?.resources?.getString(R.string.nc_upload_failed), Toast.LENGTH_LONG)
.show()
Log.e(javaClass.simpleName, "Something went wrong when trying to upload file", e)
}
} else if (requestCode == REQUEST_CODE_SELECT_CONTACT) {
val contactUri = intent?.data ?: return
val cursor: Cursor? = activity?.contentResolver!!.query(contactUri, null, null, null, null)
if (cursor != null && cursor.moveToFirst()) {
val id = cursor.getString(cursor.getColumnIndexOrThrow(ContactsContract.Contacts._ID))
val fileName = ContactUtils.getDisplayNameFromDeviceContact(context!!, id) + ".vcf"
val file = File(context?.cacheDir, fileName)
writeContactToVcfFile(cursor, file)
val shareUri = FileProvider.getUriForFile(
activity!!,
BuildConfig.APPLICATION_ID,
File(file.absolutePath)
)
uploadFiles(mutableListOf(shareUri.toString()), false)
}
cursor?.close()
} else if (requestCode == REQUEST_CODE_PICK_CAMERA) {
if (resultCode == RESULT_OK) {
when (requestCode) {
REQUEST_CODE_CHOOSE_FILE -> {
try {
checkNotNull(intent)
filesToUpload.clear()
run {
intent.clipData?.let {
for (index in 0 until it.itemCount) {
filesToUpload.add(it.getItemAt(index).uri.toString())
}
} ?: run {
checkNotNull(intent.data)
intent.data.let {
filesToUpload.add(intent.data.toString())
@ -1437,11 +1368,37 @@ class ChatController(args: Bundle) :
}
require(filesToUpload.isNotEmpty())
if (UploadAndShareFilesWorker.isStoragePermissionGranted(context!!)) {
uploadFiles(filesToUpload, false)
} else {
UploadAndShareFilesWorker.requestStoragePermission(this)
val filenamesWithLinebreaks = StringBuilder("\n")
for (file in filesToUpload) {
val filename = UriUtils.getFileName(Uri.parse(file), context)
filenamesWithLinebreaks.append(filename).append("\n")
}
val confirmationQuestion = when (filesToUpload.size) {
1 -> context?.resources?.getString(R.string.nc_upload_confirm_send_single)?.let {
String.format(it, title)
}
else -> context?.resources?.getString(R.string.nc_upload_confirm_send_multiple)?.let {
String.format(it, title)
}
}
LovelyStandardDialog(activity)
.setPositiveButtonColorRes(R.color.nc_darkGreen)
.setTitle(confirmationQuestion)
.setMessage(filenamesWithLinebreaks.toString())
.setPositiveButton(R.string.nc_yes) { v ->
if (UploadAndShareFilesWorker.isStoragePermissionGranted(context!!)) {
uploadFiles(filesToUpload, false)
} else {
UploadAndShareFilesWorker.requestStoragePermission(this)
}
}
.setNegativeButton(R.string.nc_no) {
// unused atm
}
.show()
} catch (e: IllegalStateException) {
Toast.makeText(context, context?.resources?.getString(R.string.nc_upload_failed), Toast.LENGTH_LONG)
.show()
@ -1452,6 +1409,79 @@ class ChatController(args: Bundle) :
Log.e(javaClass.simpleName, "Something went wrong when trying to upload file", e)
}
}
REQUEST_CODE_SELECT_CONTACT -> {
val contactUri = intent?.data ?: return
val cursor: Cursor? = activity?.contentResolver!!.query(contactUri, null, null, null, null)
if (cursor != null && cursor.moveToFirst()) {
val id = cursor.getString(cursor.getColumnIndexOrThrow(ContactsContract.Contacts._ID))
val fileName = ContactUtils.getDisplayNameFromDeviceContact(context!!, id) + ".vcf"
val file = File(context?.cacheDir, fileName)
writeContactToVcfFile(cursor, file)
val shareUri = FileProvider.getUriForFile(
activity!!,
BuildConfig.APPLICATION_ID,
File(file.absolutePath)
)
uploadFiles(mutableListOf(shareUri.toString()), false)
}
cursor?.close()
}
REQUEST_CODE_PICK_CAMERA -> {
if (resultCode == RESULT_OK) {
try {
checkNotNull(intent)
filesToUpload.clear()
run {
checkNotNull(intent.data)
intent.data.let {
filesToUpload.add(intent.data.toString())
}
}
require(filesToUpload.isNotEmpty())
if (UploadAndShareFilesWorker.isStoragePermissionGranted(context!!)) {
uploadFiles(filesToUpload, false)
} else {
UploadAndShareFilesWorker.requestStoragePermission(this)
}
} catch (e: IllegalStateException) {
Toast.makeText(
context,
context?.resources?.getString(R.string.nc_upload_failed),
Toast.LENGTH_LONG
)
.show()
Log.e(javaClass.simpleName, "Something went wrong when trying to upload file", e)
} catch (e: IllegalArgumentException) {
Toast.makeText(
context,
context?.resources?.getString(R.string.nc_upload_failed),
Toast.LENGTH_LONG
)
.show()
Log.e(javaClass.simpleName, "Something went wrong when trying to upload file", e)
}
}
}
REQUEST_CODE_MESSAGE_SEARCH -> {
val messageId = intent?.getStringExtra(MessageSearchActivity.RESULT_KEY_MESSAGE_ID)
messageId?.let { id ->
scrollToMessageWithId(id)
}
}
}
}
private fun scrollToMessageWithId(messageId: String) {
val position = adapter?.items?.indexOfFirst {
it.item is ChatMessage && (it.item as ChatMessage).id == messageId
}
if (position != null && position >= 0) {
binding.messagesListView.smoothScrollToPosition(position)
} else {
// TODO show error that we don't have that message?
}
}
@ -2279,6 +2309,7 @@ class ChatController(args: Bundle) :
if (adapter != null) {
adapter?.addToEnd(chatMessageList, false)
}
scrollToRequestedMessageIfNeeded()
} else {
var chatMessage: ChatMessage
@ -2394,6 +2425,12 @@ class ChatController(args: Bundle) :
}
}
private fun scrollToRequestedMessageIfNeeded() {
args.getString(BundleKeys.KEY_MESSAGE_ID)?.let {
scrollToMessageWithId(it)
}
}
private fun isSameDayNonSystemMessages(messageLeft: ChatMessage, messageRight: ChatMessage): Boolean {
return TextUtils.isEmpty(messageLeft.systemMessage) &&
TextUtils.isEmpty(messageRight.systemMessage) &&
@ -2465,32 +2502,38 @@ class ChatController(args: Bundle) :
if (CapabilitiesUtil.hasSpreedFeatureCapability(it, "read-only-rooms")) {
checkShowCallButtons()
}
val searchItem = menu.findItem(R.id.conversation_search)
searchItem.isVisible = CapabilitiesUtil.isUnifiedSearchAvailable(it)
}
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
return when (item.itemId) {
android.R.id.home -> {
(activity as MainActivity).resetConversationsList()
return true
true
}
R.id.conversation_video_call -> {
startACall(false, false)
return true
true
}
R.id.conversation_voice_call -> {
startACall(true, false)
return true
true
}
R.id.conversation_info -> {
showConversationInfoScreen()
return true
true
}
R.id.shared_items -> {
showSharedItems()
return true
true
}
else -> return super.onOptionsItemSelected(item)
R.id.conversation_search -> {
startMessageSearch()
true
}
else -> super.onOptionsItemSelected(item)
}
}
@ -2502,6 +2545,13 @@ class ChatController(args: Bundle) :
activity!!.startActivity(intent)
}
private fun startMessageSearch() {
val intent = Intent(activity, MessageSearchActivity::class.java)
intent.putExtra(KEY_CONVERSATION_NAME, currentConversation?.displayName)
intent.putExtra(KEY_ROOM_TOKEN, roomToken)
startActivityForResult(intent, REQUEST_CODE_MESSAGE_SEARCH)
}
private fun handleSystemMessages(chatMessageList: List<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

View File

@ -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);
}
return true;
}
@Override
public boolean onQueryTextSubmit(String query) {
return onQueryTextChange(query);
private void performFilterAndSearch(String filter) {
if (filter.length() >= SEARCH_MIN_CHARS) {
clearMessageSearchResults();
adapter.setFilter(filter);
adapter.filterItems();
if (CapabilitiesUtil.isUnifiedSearchAvailable(currentUser)) {
startMessageSearch(filter);
}
} else {
resetSearchResults();
}
}
private void resetSearchResults() {
clearMessageSearchResults();
adapter.setFilter("");
adapter.filterItems();
}
private void clearMessageSearchResults() {
final IHeader firstHeader = adapter.getSectionHeader(0);
if (firstHeader != null && firstHeader.getItemViewType() == MessagesTextHeaderItem.VIEW_TYPE) {
adapter.removeSection(firstHeader);
} else {
adapter.removeItemsOfType(MessageResultItem.VIEW_TYPE);
}
adapter.removeItemsOfType(LoadMoreResultsItem.VIEW_TYPE);
}
@SuppressLint("CheckResult") // handled by helper
private void startMessageSearch(final String search) {
if (swipeRefreshLayout != null) {
swipeRefreshLayout.setRefreshing(true);
}
searchHelper
.startMessageSearch(search)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
this::onMessageSearchResult,
this::onMessageSearchError);
}
@SuppressLint("CheckResult") // handled by helper
private void loadMoreMessages() {
swipeRefreshLayout.setRefreshing(true);
final Observable<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,38 +959,59 @@ public class ConversationsListController extends BaseController implements Searc
@Override
public boolean onItemClick(View view, int position) {
try {
selectedConversation = ((ConversationItem) Objects.requireNonNull(adapter.getItem(position))).getModel();
if (selectedConversation != null && getActivity() != null) {
boolean hasChatPermission =
new AttendeePermissionsUtil(selectedConversation.getPermissions()).hasChatPermission(currentUser);
if (showShareToScreen) {
if (hasChatPermission && !isReadOnlyConversation(selectedConversation)) {
handleSharedData();
showShareToScreen = false;
} else {
Toast.makeText(context, R.string.send_to_forbidden, Toast.LENGTH_LONG).show();
}
} else if (forwardMessage) {
if (hasChatPermission && !isReadOnlyConversation(selectedConversation)) {
openConversation(bundle.getString(BundleKeys.INSTANCE.getKEY_FORWARD_MSG_TEXT()));
forwardMessage = false;
} else {
Toast.makeText(context, R.string.send_to_forbidden, Toast.LENGTH_LONG).show();
}
} else {
openConversation();
}
final AbstractFlexibleItem item = adapter.getItem(position);
if (item != null) {
final int viewType = item.getItemViewType();
if (viewType == MessageResultItem.VIEW_TYPE) {
MessageResultItem messageItem = (MessageResultItem) item;
String conversationToken = messageItem.getMessageEntry().getConversationToken();
selectedMessageId = messageItem.getMessageEntry().getMessageId();
showConversationByToken(conversationToken);
} else if (viewType == LoadMoreResultsItem.VIEW_TYPE) {
loadMoreMessages();
} else if (viewType == ConversationItem.VIEW_TYPE) {
showConversation(((ConversationItem) Objects.requireNonNull(item)).getModel());
}
} catch (ClassCastException e) {
Log.w(TAG, "failed to cast clicked item to ConversationItem. Most probably a heading was clicked. This is" +
" just ignored.", e);
}
return true;
}
private void showConversationByToken(String conversationToken) {
for (AbstractFlexibleItem absItem : conversationItems) {
ConversationItem conversationItem = ((ConversationItem) absItem);
if (conversationItem.getModel().getToken().equals(conversationToken)) {
final Conversation conversation = conversationItem.getModel();
showConversation(conversation);
}
}
}
private void showConversation(@Nullable final Conversation conversation) {
selectedConversation = conversation;
if (selectedConversation != null && getActivity() != null) {
boolean hasChatPermission =
new AttendeePermissionsUtil(selectedConversation.getPermissions()).hasChatPermission(currentUser);
if (showShareToScreen) {
if (hasChatPermission && !isReadOnlyConversation(selectedConversation)) {
handleSharedData();
showShareToScreen = false;
} else {
Toast.makeText(context, R.string.send_to_forbidden, Toast.LENGTH_LONG).show();
}
} else if (forwardMessage) {
if (hasChatPermission && !isReadOnlyConversation(selectedConversation)) {
openConversation(bundle.getString(BundleKeys.INSTANCE.getKEY_FORWARD_MSG_TEXT()));
forwardMessage = false;
} else {
Toast.makeText(context, R.string.send_to_forbidden, Toast.LENGTH_LONG).show();
}
} else {
openConversation();
}
}
}
private Boolean isReadOnlyConversation(Conversation conversation) {
return conversation.getConversationReadOnlyState() ==
Conversation.ConversationReadOnlyState.CONVERSATION_READ_ONLY;
@ -1085,6 +1194,10 @@ public class ConversationsListController extends BaseController implements Searc
bundle.putString(BundleKeys.INSTANCE.getKEY_ROOM_TOKEN(), selectedConversation.getToken());
bundle.putString(BundleKeys.INSTANCE.getKEY_ROOM_ID(), selectedConversation.getRoomId());
bundle.putString(BundleKeys.INSTANCE.getKEY_SHARED_TEXT(), textToPaste);
if (selectedMessageId != null) {
bundle.putString(BundleKeys.KEY_MESSAGE_ID, selectedMessageId);
selectedMessageId = null;
}
ConductorRemapping.INSTANCE.remapChatController(getRouter(), currentUser.getId(),
selectedConversation.getToken(), bundle, false);
@ -1274,4 +1387,33 @@ public class ConversationsListController extends BaseController implements Searc
public AppBarLayoutType getAppBarLayoutType() {
return AppBarLayoutType.SEARCH_BAR;
}
public void onMessageSearchResult(@NonNull MessageSearchHelper.MessageSearchResults results) {
if (searchView.getQuery().length() > 0) {
clearMessageSearchResults();
final List<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);
}
}
}

View File

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

View File

@ -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
}

View File

@ -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"
}
}

View File

@ -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
}
}

View File

@ -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
}
}

View File

@ -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");
}
}

View File

@ -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?
)

View File

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

View File

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

View File

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

View File

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

View File

@ -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
}
}

View File

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

View File

@ -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";
}
}

View File

@ -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) {

View File

@ -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"
}

View File

@ -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?
}

View File

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

View File

@ -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)))

View File

@ -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
}
}
}

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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()
}
}

View File

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