diff --git a/CHANGES.md b/CHANGES.md index 754fb35612..4a158086a2 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -2,6 +2,7 @@ Changes in Element 1.0.9 (2020-XX-XX) =================================================== Features ✨: + - Search messages in a room - phase 1 (#2110) - Hide encrypted history (before user is invited). Can be shown if wanted in developer settings Improvements 🙌: @@ -19,7 +20,7 @@ Translations 🗣: - SDK API changes ⚠️: - - + - Search messages in a room by using Session.searchService() or Room.search() Build 🧱: - Use Update Gradle Wrapper Action diff --git a/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/session/search/SearchMessagesTest.kt b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/session/search/SearchMessagesTest.kt new file mode 100644 index 0000000000..1d4eb758ed --- /dev/null +++ b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/session/search/SearchMessagesTest.kt @@ -0,0 +1,178 @@ +/* + * Copyright 2020 New Vector Ltd + * Copyright 2020 The Matrix.org Foundation C.I.C. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.matrix.android.sdk.session.search + +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.FixMethodOrder +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 +import org.junit.runners.MethodSorters +import org.matrix.android.sdk.InstrumentedTest +import org.matrix.android.sdk.api.MatrixCallback +import org.matrix.android.sdk.api.extensions.orFalse +import org.matrix.android.sdk.api.session.events.model.toModel +import org.matrix.android.sdk.api.session.room.model.message.MessageContent +import org.matrix.android.sdk.api.session.room.timeline.TimelineSettings +import org.matrix.android.sdk.api.session.search.SearchResult +import org.matrix.android.sdk.common.CommonTestHelper +import org.matrix.android.sdk.common.CryptoTestHelper +import org.matrix.android.sdk.common.TestConstants +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit + +@RunWith(JUnit4::class) +@FixMethodOrder(MethodSorters.JVM) +class SearchMessagesTest : InstrumentedTest { + + private val MESSAGE = "Lorem ipsum dolor sit amet" + + private val commonTestHelper = CommonTestHelper(context()) + private val cryptoTestHelper = CryptoTestHelper(commonTestHelper) + + @Test + fun sendTextMessageAndSearchPartOfItUsingSession() { + val cryptoTestData = cryptoTestHelper.doE2ETestWithAliceAndBobInARoom(false) + val aliceSession = cryptoTestData.firstSession + val aliceRoomId = cryptoTestData.roomId + aliceSession.cryptoService().setWarnOnUnknownDevices(false) + val roomFromAlicePOV = aliceSession.getRoom(aliceRoomId)!! + val aliceTimeline = roomFromAlicePOV.createTimeline(null, TimelineSettings(10)) + aliceTimeline.start() + + commonTestHelper.sendTextMessage( + roomFromAlicePOV, + MESSAGE, + 2) + + run { + var lock = CountDownLatch(1) + + val eventListener = commonTestHelper.createEventListener(lock) { snapshot -> + snapshot.count { it.root.content.toModel()?.body?.startsWith(MESSAGE).orFalse() } == 2 + } + + aliceTimeline.addListener(eventListener) + commonTestHelper.await(lock) + + lock = CountDownLatch(1) + aliceSession + .searchService() + .search( + searchTerm = "lore", + limit = 10, + includeProfile = true, + afterLimit = 0, + beforeLimit = 10, + orderByRecent = true, + nextBatch = null, + roomId = aliceRoomId, + callback = object : MatrixCallback { + override fun onSuccess(data: SearchResult) { + super.onSuccess(data) + assertTrue(data.results?.size == 2) + assertTrue( + data.results + ?.all { + (it.content?.get("body") as? String)?.startsWith(MESSAGE).orFalse() + }.orFalse() + ) + lock.countDown() + } + + override fun onFailure(failure: Throwable) { + super.onFailure(failure) + fail(failure.localizedMessage) + lock.countDown() + } + } + ) + lock.await(TestConstants.timeOutMillis, TimeUnit.MILLISECONDS) + + aliceTimeline.removeAllListeners() + cryptoTestData.cleanUp(commonTestHelper) + } + + aliceSession.startSync(true) + } + + @Test + fun sendTextMessageAndSearchPartOfItUsingRoom() { + val cryptoTestData = cryptoTestHelper.doE2ETestWithAliceAndBobInARoom(false) + val aliceSession = cryptoTestData.firstSession + val aliceRoomId = cryptoTestData.roomId + aliceSession.cryptoService().setWarnOnUnknownDevices(false) + val roomFromAlicePOV = aliceSession.getRoom(aliceRoomId)!! + val aliceTimeline = roomFromAlicePOV.createTimeline(null, TimelineSettings(10)) + aliceTimeline.start() + + commonTestHelper.sendTextMessage( + roomFromAlicePOV, + MESSAGE, + 2) + + run { + var lock = CountDownLatch(1) + + val eventListener = commonTestHelper.createEventListener(lock) { snapshot -> + snapshot.count { it.root.content.toModel()?.body?.startsWith(MESSAGE).orFalse() } == 2 + } + + aliceTimeline.addListener(eventListener) + commonTestHelper.await(lock) + + lock = CountDownLatch(1) + roomFromAlicePOV + .search( + searchTerm = "lore", + limit = 10, + includeProfile = true, + afterLimit = 0, + beforeLimit = 10, + orderByRecent = true, + nextBatch = null, + callback = object : MatrixCallback { + override fun onSuccess(data: SearchResult) { + super.onSuccess(data) + assertTrue(data.results?.size == 2) + assertTrue( + data.results + ?.all { + (it.content?.get("body") as? String)?.startsWith(MESSAGE).orFalse() + }.orFalse() + ) + lock.countDown() + } + + override fun onFailure(failure: Throwable) { + super.onFailure(failure) + fail(failure.localizedMessage) + lock.countDown() + } + } + ) + lock.await(TestConstants.timeOutMillis, TimeUnit.MILLISECONDS) + + aliceTimeline.removeAllListeners() + cryptoTestData.cleanUp(commonTestHelper) + } + + aliceSession.startSync(true) + } +} diff --git a/matrix-sdk-android/src/main/AndroidManifest.xml b/matrix-sdk-android/src/main/AndroidManifest.xml index 52238f824c..eeb944d955 100644 --- a/matrix-sdk-android/src/main/AndroidManifest.xml +++ b/matrix-sdk-android/src/main/AndroidManifest.xml @@ -1,4 +1,5 @@ @@ -7,6 +8,15 @@ + + + diff --git a/vector/src/main/java/im/vector/app/core/di/FragmentModule.kt b/vector/src/main/java/im/vector/app/core/di/FragmentModule.kt index 86d59b630b..014a244bf8 100644 --- a/vector/src/main/java/im/vector/app/core/di/FragmentModule.kt +++ b/vector/src/main/java/im/vector/app/core/di/FragmentModule.kt @@ -52,6 +52,7 @@ import im.vector.app.features.home.HomeDrawerFragment import im.vector.app.features.home.LoadingFragment import im.vector.app.features.home.room.breadcrumbs.BreadcrumbsFragment import im.vector.app.features.home.room.detail.RoomDetailFragment +import im.vector.app.features.home.room.detail.search.SearchFragment import im.vector.app.features.home.room.list.RoomListFragment import im.vector.app.features.login.LoginCaptchaFragment import im.vector.app.features.login.LoginFragment @@ -570,4 +571,9 @@ interface FragmentModule { @IntoMap @FragmentKey(RoomBannedMemberListFragment::class) fun bindRoomBannedMemberListFragment(fragment: RoomBannedMemberListFragment): Fragment + + @Binds + @IntoMap + @FragmentKey(SearchFragment::class) + fun bindSearchFragment(fragment: SearchFragment): Fragment } diff --git a/vector/src/main/java/im/vector/app/core/di/ScreenComponent.kt b/vector/src/main/java/im/vector/app/core/di/ScreenComponent.kt index d337ec7977..17ec00952b 100644 --- a/vector/src/main/java/im/vector/app/core/di/ScreenComponent.kt +++ b/vector/src/main/java/im/vector/app/core/di/ScreenComponent.kt @@ -38,6 +38,7 @@ import im.vector.app.features.home.HomeActivity import im.vector.app.features.home.HomeModule import im.vector.app.features.home.room.detail.RoomDetailActivity import im.vector.app.features.home.room.detail.readreceipts.DisplayReadReceiptsBottomSheet +import im.vector.app.features.home.room.detail.search.SearchActivity import im.vector.app.features.home.room.detail.timeline.action.MessageActionsBottomSheet import im.vector.app.features.home.room.detail.timeline.edithistory.ViewEditHistoryBottomSheet import im.vector.app.features.home.room.detail.timeline.reactions.ViewReactionsBottomSheet @@ -142,6 +143,7 @@ interface ScreenComponent { fun inject(activity: VectorCallActivity) fun inject(activity: VectorAttachmentViewerActivity) fun inject(activity: VectorJitsiActivity) + fun inject(activity: SearchActivity) /* ========================================================================================== * BottomSheets diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailFragment.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailFragment.kt index 3b4795b965..51aeda2aab 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailFragment.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailFragment.kt @@ -673,10 +673,22 @@ class RoomDetailFragment @Inject constructor( roomDetailViewModel.handle(RoomDetailAction.EndCall) true } + R.id.search -> { + handleSearchAction() + true + } else -> super.onOptionsItemSelected(item) } } + private fun handleSearchAction() { + if (session.getRoom(roomDetailArgs.roomId)?.isEncrypted() == false) { + navigator.openSearch(requireContext(), roomDetailArgs.roomId) + } else { + showDialogWithMessage(getString(R.string.search_is_not_supported_in_e2e_room)) + } + } + private fun handleCallRequest(item: MenuItem) = withState(roomDetailViewModel) { state -> val roomSummary = state.asyncRoomSummary.invoke() ?: return@withState val isVideoCall = item.itemId == R.id.video_call diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailViewModel.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailViewModel.kt index 9d52933168..1b5e928843 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailViewModel.kt @@ -538,6 +538,7 @@ class RoomDetailViewModel @AssistedInject constructor( R.id.voice_call, R.id.video_call -> true // always show for discoverability R.id.hangup_call -> webRtcPeerConnectionManager.currentCall != null + R.id.search -> true else -> false } } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/search/SearchAction.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/search/SearchAction.kt new file mode 100644 index 0000000000..36d22f1914 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/search/SearchAction.kt @@ -0,0 +1,25 @@ +/* + * Copyright 2020 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.home.room.detail.search + +import im.vector.app.core.platform.VectorViewModelAction + +sealed class SearchAction : VectorViewModelAction { + data class SearchWith(val searchTerm: String) : SearchAction() + object LoadMore : SearchAction() + object Retry : SearchAction() +} diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/search/SearchActivity.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/search/SearchActivity.kt new file mode 100644 index 0000000000..f85dccbb27 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/search/SearchActivity.kt @@ -0,0 +1,79 @@ +/* + * Copyright 2020 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.home.room.detail.search + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import androidx.appcompat.widget.SearchView +import com.airbnb.mvrx.MvRx +import im.vector.app.R +import im.vector.app.core.di.ScreenComponent +import im.vector.app.core.extensions.addFragment +import im.vector.app.core.platform.VectorBaseActivity +import kotlinx.android.synthetic.main.activity_search.* + +class SearchActivity : VectorBaseActivity() { + + private val searchFragment: SearchFragment? + get() { + return supportFragmentManager.findFragmentByTag(FRAGMENT_TAG) as? SearchFragment + } + + override fun getLayoutRes() = R.layout.activity_search + + override fun injectWith(injector: ScreenComponent) { + super.injectWith(injector) + injector.inject(this) + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + configureToolbar(searchToolbar) + } + + override fun initUiAndData() { + if (isFirstCreation()) { + val fragmentArgs: SearchArgs = intent?.extras?.getParcelable(MvRx.KEY_ARG) ?: return + addFragment(R.id.searchFragmentContainer, SearchFragment::class.java, fragmentArgs, FRAGMENT_TAG) + } + searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener { + override fun onQueryTextSubmit(query: String): Boolean { + searchFragment?.search(query) + return true + } + + override fun onQueryTextChange(newText: String): Boolean { + return true + } + }) + // Open the keyboard immediately + searchView.requestFocus() + } + + companion object { + private const val FRAGMENT_TAG = "SearchFragment" + + fun newIntent(context: Context, args: SearchArgs): Intent { + return Intent(context, SearchActivity::class.java).apply { + // If we do that we will have the same room two times on the stack. Let's allow infinite stack for the moment. + // flags = Intent.FLAG_ACTIVITY_REORDER_TO_FRONT + putExtra(MvRx.KEY_ARG, args) + } + } + } +} diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/search/SearchFragment.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/search/SearchFragment.kt new file mode 100644 index 0000000000..666f5b3d38 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/search/SearchFragment.kt @@ -0,0 +1,128 @@ +/* + * Copyright 2020 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.home.room.detail.search + +import android.os.Bundle +import android.os.Parcelable +import android.view.View +import androidx.core.content.ContextCompat +import androidx.recyclerview.widget.LinearLayoutManager +import com.airbnb.mvrx.Fail +import com.airbnb.mvrx.Loading +import com.airbnb.mvrx.Success +import com.airbnb.mvrx.args +import com.airbnb.mvrx.fragmentViewModel +import com.airbnb.mvrx.withState +import im.vector.app.R +import im.vector.app.core.extensions.cleanup +import im.vector.app.core.extensions.configureWith +import im.vector.app.core.extensions.hideKeyboard +import im.vector.app.core.extensions.trackItemsVisibilityChange +import im.vector.app.core.platform.StateView +import im.vector.app.core.platform.VectorBaseFragment +import kotlinx.android.parcel.Parcelize +import kotlinx.android.synthetic.main.fragment_search.* +import org.matrix.android.sdk.api.session.events.model.Event +import javax.inject.Inject + +@Parcelize +data class SearchArgs( + val roomId: String +) : Parcelable + +class SearchFragment @Inject constructor( + val viewModelFactory: SearchViewModel.Factory, + private val controller: SearchResultController +) : VectorBaseFragment(), StateView.EventCallback, SearchResultController.Listener { + + private val fragmentArgs: SearchArgs by args() + private val searchViewModel: SearchViewModel by fragmentViewModel() + + private var pendingScrollToPosition: Int? = null + + override fun getLayoutResId() = R.layout.fragment_search + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + stateView.contentView = searchResultRecycler + stateView.eventCallback = this + + configureRecyclerView() + } + + private fun configureRecyclerView() { + searchResultRecycler.trackItemsVisibilityChange() + searchResultRecycler.configureWith(controller, showDivider = false) + (searchResultRecycler.layoutManager as? LinearLayoutManager)?.stackFromEnd = true + controller.listener = this + + controller.addModelBuildListener { + pendingScrollToPosition?.let { + searchResultRecycler.smoothScrollToPosition(it) + } + } + } + + override fun onDestroy() { + super.onDestroy() + searchResultRecycler?.cleanup() + controller.listener = null + } + + override fun invalidate() = withState(searchViewModel) { state -> + if (state.searchResult.isNullOrEmpty()) { + when (state.asyncSearchRequest) { + is Loading -> { + stateView.state = StateView.State.Loading + } + is Fail -> { + stateView.state = StateView.State.Error(errorFormatter.toHumanReadable(state.asyncSearchRequest.error)) + } + is Success -> { + stateView.state = StateView.State.Empty( + title = getString(R.string.search_no_results), + image = ContextCompat.getDrawable(requireContext(), R.drawable.ic_search_no_results)) + } + } + } else { + pendingScrollToPosition = (state.lastBatchSize - 1).coerceAtLeast(0) + + stateView.state = StateView.State.Content + controller.setData(state) + } + } + + fun search(query: String) { + view?.hideKeyboard() + searchViewModel.handle(SearchAction.SearchWith(query)) + } + + override fun onRetryClicked() { + searchViewModel.handle(SearchAction.Retry) + } + + override fun onItemClicked(event: Event) { + event.roomId?.let { + navigator.openRoom(requireContext(), it, event.eventId) + } + } + + override fun loadMore() { + searchViewModel.handle(SearchAction.LoadMore) + } +} diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/search/SearchResultController.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/search/SearchResultController.kt new file mode 100644 index 0000000000..c917c4557d --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/search/SearchResultController.kt @@ -0,0 +1,96 @@ +/* + * Copyright 2020 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.home.room.detail.search + +import com.airbnb.epoxy.TypedEpoxyController +import com.airbnb.epoxy.VisibilityState +import im.vector.app.core.date.DateFormatKind +import im.vector.app.core.date.VectorDateFormatter +import im.vector.app.core.epoxy.loadingItem +import im.vector.app.core.ui.list.genericItemHeader +import im.vector.app.features.home.AvatarRenderer +import org.matrix.android.sdk.api.session.Session +import org.matrix.android.sdk.api.session.events.model.Event +import org.matrix.android.sdk.api.session.search.EventAndSender +import org.matrix.android.sdk.api.util.toMatrixItem +import java.util.Calendar +import javax.inject.Inject + +class SearchResultController @Inject constructor( + private val session: Session, + private val avatarRenderer: AvatarRenderer, + private val dateFormatter: VectorDateFormatter +) : TypedEpoxyController() { + + var listener: Listener? = null + + private var idx = 0 + + interface Listener { + fun onItemClicked(event: Event) + fun loadMore() + } + + init { + setData(null) + } + + override fun buildModels(data: SearchViewState?) { + data ?: return + + if (data.hasMoreResult) { + loadingItem { + // Always use a different id, because we can be notified several times of visibility state changed + id("loadMore${idx++}") + onVisibilityStateChanged { _, _, visibilityState -> + if (visibilityState == VisibilityState.VISIBLE) { + listener?.loadMore() + } + } + } + } + + buildSearchResultItems(data.searchResult) + } + + private fun buildSearchResultItems(events: List) { + var lastDate: Calendar? = null + + events.forEach { eventAndSender -> + val eventDate = Calendar.getInstance().apply { + timeInMillis = eventAndSender.event.originServerTs ?: System.currentTimeMillis() + } + if (lastDate?.get(Calendar.DAY_OF_YEAR) != eventDate.get(Calendar.DAY_OF_YEAR)) { + genericItemHeader { + id(eventDate.hashCode()) + text(dateFormatter.format(eventDate.timeInMillis, DateFormatKind.EDIT_HISTORY_HEADER)) + } + } + lastDate = eventDate + + searchResultItem { + id(eventAndSender.event.eventId) + avatarRenderer(avatarRenderer) + dateFormatter(dateFormatter) + event(eventAndSender.event) + sender(eventAndSender.sender + ?: eventAndSender.event.senderId?.let { session.getUser(it) }?.toMatrixItem()) + listener { listener?.onItemClicked(eventAndSender.event) } + } + } + } +} diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/search/SearchResultItem.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/search/SearchResultItem.kt new file mode 100644 index 0000000000..10407c64e0 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/search/SearchResultItem.kt @@ -0,0 +1,61 @@ +/* + * Copyright 2020 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.home.room.detail.search + +import android.widget.ImageView +import android.widget.TextView +import com.airbnb.epoxy.EpoxyAttribute +import com.airbnb.epoxy.EpoxyModelClass +import im.vector.app.R +import im.vector.app.core.date.DateFormatKind +import im.vector.app.core.date.VectorDateFormatter +import im.vector.app.core.epoxy.ClickListener +import im.vector.app.core.epoxy.VectorEpoxyHolder +import im.vector.app.core.epoxy.VectorEpoxyModel +import im.vector.app.core.epoxy.onClick +import im.vector.app.core.extensions.setTextOrHide +import im.vector.app.features.home.AvatarRenderer +import org.matrix.android.sdk.api.session.events.model.Event +import org.matrix.android.sdk.api.util.MatrixItem + +@EpoxyModelClass(layout = R.layout.item_search_result) +abstract class SearchResultItem : VectorEpoxyModel() { + + @EpoxyAttribute lateinit var avatarRenderer: AvatarRenderer + @EpoxyAttribute var dateFormatter: VectorDateFormatter? = null + @EpoxyAttribute lateinit var event: Event + @EpoxyAttribute var sender: MatrixItem? = null + @EpoxyAttribute var listener: ClickListener? = null + + override fun bind(holder: Holder) { + super.bind(holder) + + holder.view.onClick(listener) + sender?.let { avatarRenderer.render(it, holder.avatarImageView) } + holder.memberNameView.setTextOrHide(sender?.getBestName()) + holder.timeView.text = dateFormatter?.format(event.originServerTs, DateFormatKind.MESSAGE_SIMPLE) + // TODO Improve that (use formattedBody, etc.) + holder.contentView.text = event.content?.get("body") as? String + } + + class Holder : VectorEpoxyHolder() { + val avatarImageView by bind(R.id.messageAvatarImageView) + val memberNameView by bind(R.id.messageMemberNameView) + val timeView by bind(R.id.messageTimeView) + val contentView by bind(R.id.messageContentView) + } +} diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/search/SearchViewEvents.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/search/SearchViewEvents.kt new file mode 100644 index 0000000000..6f07cb765c --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/search/SearchViewEvents.kt @@ -0,0 +1,23 @@ +/* + * Copyright 2020 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.home.room.detail.search + +import im.vector.app.core.platform.VectorViewEvents + +sealed class SearchViewEvents : VectorViewEvents { + data class Failure(val throwable: Throwable) : SearchViewEvents() +} diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/search/SearchViewModel.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/search/SearchViewModel.kt new file mode 100644 index 0000000000..21a2d18e71 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/search/SearchViewModel.kt @@ -0,0 +1,159 @@ +/* + * Copyright 2020 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.home.room.detail.search + +import androidx.lifecycle.viewModelScope +import com.airbnb.mvrx.Fail +import com.airbnb.mvrx.FragmentViewModelContext +import com.airbnb.mvrx.Loading +import com.airbnb.mvrx.MvRxViewModelFactory +import com.airbnb.mvrx.Success +import com.airbnb.mvrx.ViewModelContext +import com.squareup.inject.assisted.Assisted +import com.squareup.inject.assisted.AssistedInject +import im.vector.app.core.extensions.exhaustive +import im.vector.app.core.platform.VectorViewModel +import kotlinx.coroutines.launch +import org.matrix.android.sdk.api.failure.Failure +import org.matrix.android.sdk.api.session.Session +import org.matrix.android.sdk.api.session.room.Room +import org.matrix.android.sdk.api.session.search.SearchResult +import org.matrix.android.sdk.api.util.Cancelable +import org.matrix.android.sdk.internal.util.awaitCallback + +class SearchViewModel @AssistedInject constructor( + @Assisted private val initialState: SearchViewState, + session: Session +) : VectorViewModel(initialState) { + + private var room: Room? = session.getRoom(initialState.roomId) + + private var currentTask: Cancelable? = null + + private var nextBatch: String? = null + + @AssistedInject.Factory + interface Factory { + fun create(initialState: SearchViewState): SearchViewModel + } + + companion object : MvRxViewModelFactory { + + @JvmStatic + override fun create(viewModelContext: ViewModelContext, state: SearchViewState): SearchViewModel? { + val fragment: SearchFragment = (viewModelContext as FragmentViewModelContext).fragment() + return fragment.viewModelFactory.create(state) + } + } + + override fun handle(action: SearchAction) { + when (action) { + is SearchAction.SearchWith -> handleSearchWith(action) + is SearchAction.LoadMore -> handleLoadMore() + is SearchAction.Retry -> handleRetry() + }.exhaustive + } + + private fun handleSearchWith(action: SearchAction.SearchWith) { + if (action.searchTerm.isNotEmpty()) { + setState { + copy( + searchResult = emptyList(), + hasMoreResult = false, + lastBatchSize = 0, + searchTerm = action.searchTerm + ) + } + startSearching(false) + } + } + + private fun handleLoadMore() { + startSearching(true) + } + + private fun handleRetry() { + startSearching(false) + } + + private fun startSearching(isNextBatch: Boolean) = withState { state -> + if (state.searchTerm == null) return@withState + + // There is no batch to retrieve + if (isNextBatch && nextBatch == null) return@withState + + // Show full screen loading just for the clean search + if (!isNextBatch) { + setState { + copy( + asyncSearchRequest = Loading() + ) + } + } + + currentTask?.cancel() + + viewModelScope.launch { + try { + val result = awaitCallback { + currentTask = room?.search( + searchTerm = state.searchTerm, + nextBatch = nextBatch, + orderByRecent = true, + beforeLimit = 0, + afterLimit = 0, + includeProfile = true, + limit = 20, + callback = it + ) + } + onSearchResultSuccess(result) + } catch (failure: Throwable) { + if (failure is Failure.Cancelled) return@launch + + _viewEvents.post(SearchViewEvents.Failure(failure)) + setState { + copy( + asyncSearchRequest = Fail(failure) + ) + } + } + } + } + + private fun onSearchResultSuccess(searchResult: SearchResult) = withState { state -> + val accumulatedResult = searchResult.results.orEmpty().plus(state.searchResult) + + // Note: We do not care about the highlights for the moment, but it will be the same algorithm + + nextBatch = searchResult.nextBatch + + setState { + copy( + searchResult = accumulatedResult, + hasMoreResult = !nextBatch.isNullOrEmpty(), + lastBatchSize = searchResult.results.orEmpty().size, + asyncSearchRequest = Success(Unit) + ) + } + } + + override fun onCleared() { + currentTask?.cancel() + super.onCleared() + } +} diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/search/SearchViewState.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/search/SearchViewState.kt new file mode 100644 index 0000000000..9f700b6e31 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/search/SearchViewState.kt @@ -0,0 +1,37 @@ +/* + * Copyright 2020 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.home.room.detail.search + +import com.airbnb.mvrx.Async +import com.airbnb.mvrx.MvRxState +import com.airbnb.mvrx.Uninitialized +import org.matrix.android.sdk.api.session.search.EventAndSender + +data class SearchViewState( + // Accumulated search result + val searchResult: List = emptyList(), + val hasMoreResult: Boolean = false, + // Last batch size, will help RecyclerView to position itself + val lastBatchSize: Int = 0, + val searchTerm: String? = null, + val roomId: String = "", + // Current pagination request + val asyncSearchRequest: Async = Uninitialized +) : MvRxState { + + constructor(args: SearchArgs) : this(roomId = args.roomId) +} diff --git a/vector/src/main/java/im/vector/app/features/navigation/DefaultNavigator.kt b/vector/src/main/java/im/vector/app/features/navigation/DefaultNavigator.kt index 3a8d302fc7..5ad600dfff 100644 --- a/vector/src/main/java/im/vector/app/features/navigation/DefaultNavigator.kt +++ b/vector/src/main/java/im/vector/app/features/navigation/DefaultNavigator.kt @@ -43,6 +43,8 @@ import im.vector.app.features.crypto.verification.VerificationBottomSheet import im.vector.app.features.debug.DebugMenuActivity import im.vector.app.features.home.room.detail.RoomDetailActivity import im.vector.app.features.home.room.detail.RoomDetailArgs +import im.vector.app.features.home.room.detail.search.SearchActivity +import im.vector.app.features.home.room.detail.search.SearchArgs import im.vector.app.features.home.room.detail.widget.WidgetRequestCodes import im.vector.app.features.home.room.filtered.FilteredRoomsActivity import im.vector.app.features.invite.InviteUsersToRoomActivity @@ -329,6 +331,11 @@ class DefaultNavigator @Inject constructor( } } + override fun openSearch(context: Context, roomId: String) { + val intent = SearchActivity.newIntent(context, SearchArgs(roomId)) + context.startActivity(intent) + } + private fun startActivity(context: Context, intent: Intent, buildTask: Boolean) { if (buildTask) { val stackBuilder = TaskStackBuilder.create(context) diff --git a/vector/src/main/java/im/vector/app/features/navigation/Navigator.kt b/vector/src/main/java/im/vector/app/features/navigation/Navigator.kt index e92fea267f..b2efb1e931 100644 --- a/vector/src/main/java/im/vector/app/features/navigation/Navigator.kt +++ b/vector/src/main/java/im/vector/app/features/navigation/Navigator.kt @@ -109,4 +109,6 @@ interface Navigator { view: View, inMemory: List = emptyList(), options: ((MutableList>) -> Unit)?) + + fun openSearch(context: Context, roomId: String) } diff --git a/vector/src/main/java/im/vector/app/features/roomprofile/uploads/RoomUploadsFragment.kt b/vector/src/main/java/im/vector/app/features/roomprofile/uploads/RoomUploadsFragment.kt index b3f5047df4..876b585a59 100644 --- a/vector/src/main/java/im/vector/app/features/roomprofile/uploads/RoomUploadsFragment.kt +++ b/vector/src/main/java/im/vector/app/features/roomprofile/uploads/RoomUploadsFragment.kt @@ -23,23 +23,21 @@ import com.airbnb.mvrx.args import com.airbnb.mvrx.fragmentViewModel import com.airbnb.mvrx.withState import com.google.android.material.tabs.TabLayoutMediator -import org.matrix.android.sdk.api.util.toMatrixItem import im.vector.app.R import im.vector.app.core.extensions.exhaustive import im.vector.app.core.intent.getMimeTypeFromUri import im.vector.app.core.platform.VectorBaseFragment -import im.vector.app.core.resources.StringProvider import im.vector.app.core.utils.saveMedia import im.vector.app.core.utils.shareMedia import im.vector.app.features.home.AvatarRenderer import im.vector.app.features.notifications.NotificationUtils import im.vector.app.features.roomprofile.RoomProfileArgs import kotlinx.android.synthetic.main.fragment_room_uploads.* +import org.matrix.android.sdk.api.util.toMatrixItem import javax.inject.Inject class RoomUploadsFragment @Inject constructor( private val viewModelFactory: RoomUploadsViewModel.Factory, - private val stringProvider: StringProvider, private val avatarRenderer: AvatarRenderer, private val notificationUtils: NotificationUtils ) : VectorBaseFragment(), RoomUploadsViewModel.Factory by viewModelFactory { @@ -58,8 +56,8 @@ class RoomUploadsFragment @Inject constructor( TabLayoutMediator(roomUploadsTabs, roomUploadsViewPager) { tab, position -> when (position) { - 0 -> tab.text = stringProvider.getString(R.string.uploads_media_title) - 1 -> tab.text = stringProvider.getString(R.string.uploads_files_title) + 0 -> tab.text = getString(R.string.uploads_media_title) + 1 -> tab.text = getString(R.string.uploads_files_title) } }.attach() @@ -70,7 +68,7 @@ class RoomUploadsFragment @Inject constructor( is RoomUploadsViewEvents.FileReadyForSharing -> { shareMedia(requireContext(), it.file, getMimeTypeFromUri(requireContext(), it.file.toUri())) } - is RoomUploadsViewEvents.FileReadyForSaving -> { + is RoomUploadsViewEvents.FileReadyForSaving -> { saveMedia( context = requireContext(), file = it.file, @@ -79,7 +77,7 @@ class RoomUploadsFragment @Inject constructor( notificationUtils = notificationUtils ) } - is RoomUploadsViewEvents.Failure -> showFailure(it.throwable) + is RoomUploadsViewEvents.Failure -> showFailure(it.throwable) }.exhaustive } } diff --git a/vector/src/main/res/drawable/ic_search_no_results.xml b/vector/src/main/res/drawable/ic_search_no_results.xml new file mode 100644 index 0000000000..24c0f00131 --- /dev/null +++ b/vector/src/main/res/drawable/ic_search_no_results.xml @@ -0,0 +1,20 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/vector/src/main/res/layout/activity_search.xml b/vector/src/main/res/layout/activity_search.xml new file mode 100644 index 0000000000..2268bf932c --- /dev/null +++ b/vector/src/main/res/layout/activity_search.xml @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/vector/src/main/res/layout/fragment_search.xml b/vector/src/main/res/layout/fragment_search.xml new file mode 100644 index 0000000000..330e70d86b --- /dev/null +++ b/vector/src/main/res/layout/fragment_search.xml @@ -0,0 +1,15 @@ + + + + + + \ No newline at end of file diff --git a/vector/src/main/res/layout/item_search_result.xml b/vector/src/main/res/layout/item_search_result.xml new file mode 100644 index 0000000000..73f949f777 --- /dev/null +++ b/vector/src/main/res/layout/item_search_result.xml @@ -0,0 +1,62 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/vector/src/main/res/menu/menu_timeline.xml b/vector/src/main/res/menu/menu_timeline.xml index 6cdbbed424..e0566ccf6d 100644 --- a/vector/src/main/res/menu/menu_timeline.xml +++ b/vector/src/main/res/menu/menu_timeline.xml @@ -3,6 +3,11 @@ xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools"> + + MESSAGES PEOPLE FILES + Searching in encrypted rooms is not supported yet. JOIN