From 7253aaaa14066e4301847837eed8b78d2758375c Mon Sep 17 00:00:00 2001 From: Greyson Parrelli Date: Thu, 19 Mar 2026 12:20:10 -0400 Subject: [PATCH] Add the ability to filter search by date and author. --- app/src/main/AndroidManifest.xml | 7 + .../thoughtcrime/securesms/MainActivity.kt | 8 + .../settings/app/labs/LabsSettingsEvents.kt | 1 + .../settings/app/labs/LabsSettingsFragment.kt | 9 + .../settings/app/labs/LabsSettingsState.kt | 3 +- .../app/labs/LabsSettingsViewModel.kt | 7 +- .../paged/ContactSearchConfiguration.kt | 6 + .../contacts/paged/ContactSearchMediator.kt | 5 + .../paged/ContactSearchPagedDataSource.kt | 2 +- .../contacts/paged/ContactSearchState.kt | 4 +- .../contacts/paged/ContactSearchViewModel.kt | 5 + .../ConversationListFragment.java | 101 ++++++- .../securesms/database/SearchTable.kt | 37 +++ .../securesms/keyvalue/LabsValues.kt | 3 + .../securesms/main/MainToolbar.kt | 48 +++- .../securesms/main/MainToolbarViewModel.kt | 6 + .../securesms/search/SearchFilter.kt | 17 ++ .../search/SearchFilterBottomSheet.kt | 270 ++++++++++++++++++ .../securesms/search/SearchRepository.java | 36 ++- .../search/SingleContactSelectionActivity.kt | 42 +++ app/src/main/res/values/strings.xml | 21 ++ 21 files changed, 612 insertions(+), 26 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/search/SearchFilter.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/search/SearchFilterBottomSheet.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/search/SingleContactSelectionActivity.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 5401e10130..b7c27e7a31 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -458,6 +458,13 @@ android:label="@string/AndroidManifest__select_contacts" android:windowSoftInputMode="stateHidden" /> + + + if (fragment is ConversationListFragment) { + fragment.showSearchFilterBottomSheet() + } + } + } + override fun onNotificationProfileTooltipDismissed() { SignalStore.notificationProfile.hasSeenTooltip = true toolbarViewModel.setShowNotificationProfilesTooltip(false) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/labs/LabsSettingsEvents.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/labs/LabsSettingsEvents.kt index 32307b024f..f64e401fa3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/labs/LabsSettingsEvents.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/labs/LabsSettingsEvents.kt @@ -10,4 +10,5 @@ sealed interface LabsSettingsEvents { data class ToggleStoryArchive(val enabled: Boolean) : LabsSettingsEvents data class ToggleIncognito(val enabled: Boolean) : LabsSettingsEvents data class ToggleGroupSuggestionsForMembers(val enabled: Boolean) : LabsSettingsEvents + data class ToggleBetterSearch(val enabled: Boolean) : LabsSettingsEvents } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/labs/LabsSettingsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/labs/LabsSettingsFragment.kt index c734b43538..8a95ea9d58 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/labs/LabsSettingsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/labs/LabsSettingsFragment.kt @@ -124,6 +124,15 @@ private fun LabsSettingsContent( onCheckChanged = { onEvent(LabsSettingsEvents.ToggleGroupSuggestionsForMembers(it)) } ) } + + item { + Rows.ToggleRow( + checked = state.betterSearch, + text = "Better Search", + label = "Filter search results by date range and author. Adds a filter button to the search toolbar.", + onCheckChanged = { onEvent(LabsSettingsEvents.ToggleBetterSearch(it)) } + ) + } } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/labs/LabsSettingsState.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/labs/LabsSettingsState.kt index e52f274e96..c8b07d3c8d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/labs/LabsSettingsState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/labs/LabsSettingsState.kt @@ -12,5 +12,6 @@ data class LabsSettingsState( val individualChatPlaintextExport: Boolean = false, val storyArchive: Boolean = false, val incognito: Boolean = false, - val groupSuggestionsForMembers: Boolean = false + val groupSuggestionsForMembers: Boolean = false, + val betterSearch: Boolean = false ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/labs/LabsSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/labs/LabsSettingsViewModel.kt index 3803a9effb..a74636c436 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/labs/LabsSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/labs/LabsSettingsViewModel.kt @@ -33,6 +33,10 @@ class LabsSettingsViewModel : ViewModel() { SignalStore.labs.groupSuggestionsForMembers = event.enabled _state.value = _state.value.copy(groupSuggestionsForMembers = event.enabled) } + is LabsSettingsEvents.ToggleBetterSearch -> { + SignalStore.labs.betterSearch = event.enabled + _state.value = _state.value.copy(betterSearch = event.enabled) + } } } @@ -41,7 +45,8 @@ class LabsSettingsViewModel : ViewModel() { individualChatPlaintextExport = SignalStore.labs.individualChatPlaintextExport, storyArchive = SignalStore.labs.storyArchive, incognito = SignalStore.labs.incognito, - groupSuggestionsForMembers = SignalStore.labs.groupSuggestionsForMembers + groupSuggestionsForMembers = SignalStore.labs.groupSuggestionsForMembers, + betterSearch = SignalStore.labs.betterSearch ) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchConfiguration.kt b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchConfiguration.kt index 4ee3033c12..7cd52eceb9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchConfiguration.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchConfiguration.kt @@ -2,12 +2,14 @@ package org.thoughtcrime.securesms.contacts.paged import org.thoughtcrime.securesms.contacts.HeaderAction import org.thoughtcrime.securesms.database.RecipientTable +import org.thoughtcrime.securesms.search.SearchFilter /** * A strongly typed descriptor of how a given list of contacts should be formatted */ class ContactSearchConfiguration private constructor( val query: String?, + val searchFilter: SearchFilter, val sections: List
, val emptyStateSections: List
) { @@ -335,6 +337,7 @@ class ContactSearchConfiguration private constructor( private val sections: MutableList
= mutableListOf() override var query: String? = null + override var searchFilter: SearchFilter = SearchFilter.EMPTY override fun addSection(section: Section) { sections.add(section) @@ -357,6 +360,7 @@ class ContactSearchConfiguration private constructor( private val emptyState = EmptyStateBuilder() override var query: String? = null + override var searchFilter: SearchFilter = SearchFilter.EMPTY override fun addSection(section: Section) { sections.add(section) @@ -369,6 +373,7 @@ class ContactSearchConfiguration private constructor( fun build(): ContactSearchConfiguration { return ContactSearchConfiguration( query = query, + searchFilter = searchFilter, sections = sections, emptyStateSections = emptyState.build() ) @@ -380,6 +385,7 @@ class ContactSearchConfiguration private constructor( */ interface Builder { var query: String? + var searchFilter: SearchFilter fun arbitrary(first: String, vararg rest: String) { addSection(Section.Arbitrary(setOf(first) + rest.toSet())) diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchMediator.kt b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchMediator.kt index 3864851f38..4f50f8bacd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchMediator.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchMediator.kt @@ -14,6 +14,7 @@ import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.conversationlist.chatfilter.ConversationFilterRequest import org.thoughtcrime.securesms.groups.SelectionLimits import org.thoughtcrime.securesms.keyvalue.SignalStore +import org.thoughtcrime.securesms.search.SearchFilter import org.thoughtcrime.securesms.search.SearchRepository import org.thoughtcrime.securesms.stories.settings.custom.PrivateStorySettingsFragment import org.thoughtcrime.securesms.stories.settings.my.MyStorySettingsFragment @@ -135,6 +136,10 @@ class ContactSearchMediator( viewModel.setConversationFilterRequest(conversationFilterRequest) } + fun onSearchFilterChanged(searchFilter: SearchFilter) { + viewModel.setSearchFilter(searchFilter) + } + fun setKeysSelected(keys: Set) { Log.d(TAG, "setKeysSelected() Keys: ${keys.map { it.toString() }}") viewModel.setKeysSelected(callbacks.onBeforeContactsSelected(null, keys)) diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchPagedDataSource.kt b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchPagedDataSource.kt index 864663e327..6ef5d9a6f7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchPagedDataSource.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchPagedDataSource.kt @@ -457,7 +457,7 @@ class ContactSearchPagedDataSource( check(searchRepository != null) if (searchCache.messageSearchResult == null && query != null) { - searchCache = searchCache.copy(messageSearchResult = searchRepository.queryMessagesSync(query)) + searchCache = searchCache.copy(messageSearchResult = searchRepository.queryMessagesSync(query, contactConfiguration.searchFilter)) } return if (query != null) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchState.kt b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchState.kt index 6d09ae7b3e..14f43484c1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchState.kt @@ -1,6 +1,7 @@ package org.thoughtcrime.securesms.contacts.paged import org.thoughtcrime.securesms.conversationlist.chatfilter.ConversationFilterRequest +import org.thoughtcrime.securesms.search.SearchFilter /** * Simple search state for contacts. @@ -9,5 +10,6 @@ data class ContactSearchState( val query: String? = null, val conversationFilterRequest: ConversationFilterRequest? = null, val expandedSections: Set = emptySet(), - val groupStories: Set = emptySet() + val groupStories: Set = emptySet(), + val searchFilter: SearchFilter = SearchFilter.EMPTY ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchViewModel.kt index 7d8710c3c0..ee6c7a59bb 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchViewModel.kt @@ -23,6 +23,7 @@ import org.thoughtcrime.securesms.conversationlist.chatfilter.ConversationFilter import org.thoughtcrime.securesms.database.model.DistributionListPrivacyMode import org.thoughtcrime.securesms.groups.SelectionLimits import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.search.SearchFilter import org.thoughtcrime.securesms.search.SearchRepository import org.thoughtcrime.securesms.util.livedata.Store import org.whispersystems.signalservice.api.util.Preconditions @@ -93,6 +94,10 @@ class ContactSearchViewModel( configurationStore.update { it.copy(conversationFilterRequest = conversationFilterRequest) } } + fun setSearchFilter(searchFilter: SearchFilter) { + configurationStore.update { it.copy(searchFilter = searchFilter) } + } + fun expandSection(sectionKey: ContactSearchConfiguration.SectionKey) { configurationStore.update { it.copy(expandedSections = it.expandedSections + sectionKey) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java index 3725e08ef1..bac4ac7e85 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java @@ -17,7 +17,9 @@ package org.thoughtcrime.securesms.conversationlist; import android.annotation.SuppressLint; +import android.app.Activity; import android.content.Context; +import android.content.Intent; import android.content.res.ColorStateList; import android.content.res.Configuration; import android.content.res.Resources; @@ -35,11 +37,12 @@ import android.view.inputmethod.InputMethodManager; import android.widget.FrameLayout; import androidx.activity.OnBackPressedCallback; +import androidx.activity.result.ActivityResultLauncher; +import androidx.activity.result.contract.ActivityResultContracts; import androidx.annotation.ColorInt; import androidx.annotation.DrawableRes; import androidx.annotation.NonNull; import androidx.annotation.Nullable; -import androidx.annotation.WorkerThread; import androidx.appcompat.content.res.AppCompatResources; import org.signal.core.ui.compose.Snackbars; import androidx.compose.ui.platform.ComposeView; @@ -64,10 +67,8 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder; import org.greenrobot.eventbus.EventBus; import org.greenrobot.eventbus.Subscribe; import org.greenrobot.eventbus.ThreadMode; -import org.signal.core.ui.compose.SignalIcons; import org.signal.core.util.DimensionUnit; import org.signal.core.util.Stopwatch; -import org.signal.core.util.ThreadUtil; import org.signal.core.util.concurrent.LifecycleDisposable; import org.signal.core.util.concurrent.SignalExecutors; import org.signal.core.util.concurrent.SimpleTask; @@ -113,12 +114,14 @@ import org.thoughtcrime.securesms.components.snackbars.SnackbarState; import org.thoughtcrime.securesms.components.spoiler.SpoilerAnnotation; import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaControllerOwner; import org.thoughtcrime.securesms.components.voice.VoiceNotePlayerView; +import org.thoughtcrime.securesms.contacts.ContactSelectionDisplayMode; import org.thoughtcrime.securesms.contacts.paged.ContactSearchAdapter; import org.thoughtcrime.securesms.contacts.paged.ContactSearchConfiguration; import org.thoughtcrime.securesms.contacts.paged.ContactSearchData; import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey; import org.thoughtcrime.securesms.contacts.paged.ContactSearchMediator; import org.thoughtcrime.securesms.contacts.paged.ContactSearchState; +import org.thoughtcrime.securesms.contacts.selection.ContactSelectionArguments; import org.thoughtcrime.securesms.conversation.ConversationUpdateTick; import org.thoughtcrime.securesms.conversationlist.chatfilter.ConversationFilterRequest; import org.thoughtcrime.securesms.conversationlist.chatfilter.ConversationFilterSource; @@ -148,6 +151,8 @@ import org.thoughtcrime.securesms.ratelimit.RecaptchaProofBottomSheetFragment; import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.recipients.RecipientId; import org.thoughtcrime.securesms.search.MessageResult; +import org.thoughtcrime.securesms.search.SearchFilter; +import org.thoughtcrime.securesms.search.SearchFilterBottomSheet; import org.thoughtcrime.securesms.sms.MessageSender; import org.thoughtcrime.securesms.util.AppForegroundObserver; import org.thoughtcrime.securesms.util.AppStartup; @@ -232,6 +237,13 @@ public class ConversationListFragment extends MainFragment implements Conversati private MainToolbarViewModel mainToolbarViewModel; private ChatListBackHandler chatListBackHandler; + private SearchFilter activeSearchFilter = SearchFilter.EMPTY; + private ActivityResultLauncher searchFilterContactPickerLauncher; + + private static final String EXTRA_FILTER_START_DATE = "filter_start_date"; + private static final String EXTRA_FILTER_END_DATE = "filter_end_date"; + private static final String EXTRA_FILTER_AUTHOR_ID = "filter_author_id"; + private BannerManager bannerManager; private SignalProgressDialog progressDialog; @@ -258,6 +270,33 @@ public class ConversationListFragment extends MainFragment implements Conversati startupStopwatch = new Stopwatch("startup"); mainToolbarViewModel = new ViewModelProvider(requireActivity()).get(MainToolbarViewModel.class); mainNavigationViewModel = new ViewModelProvider(requireActivity(), new MainNavigationViewModel.Factory()).get(MainNavigationViewModel.class); + + searchFilterContactPickerLauncher = registerForActivityResult( + new ActivityResultContracts.StartActivityForResult(), + result -> { + Intent launchIntent = result.getData(); + long startDate = launchIntent != null ? launchIntent.getLongExtra(EXTRA_FILTER_START_DATE, -1) : -1; + long endDate = launchIntent != null ? launchIntent.getLongExtra(EXTRA_FILTER_END_DATE, -1) : -1; + String authorStr = launchIntent != null ? launchIntent.getStringExtra(EXTRA_FILTER_AUTHOR_ID) : null; + + RecipientId selectedAuthor = authorStr != null ? RecipientId.from(Long.parseLong(authorStr)) : null; + if (result.getResultCode() == Activity.RESULT_OK && result.getData() != null) { + List recipients = result.getData().getParcelableArrayListExtra( + org.thoughtcrime.securesms.search.SingleContactSelectionActivity.KEY_SELECTED_RECIPIENT + ); + if (recipients != null && !recipients.isEmpty()) { + selectedAuthor = recipients.get(0); + } + } + + SearchFilterBottomSheet.show( + getParentFragmentManager(), + startDate != -1 ? startDate : null, + endDate != -1 ? endDate : null, + selectedAuthor + ); + } + ); } @Override @@ -320,6 +359,8 @@ public class ConversationListFragment extends MainFragment implements Conversati searchAdapter = contactSearchMediator.getAdapter(); + initializeSearchFilterListener(); + if (WindowSizeClassExtensionsKt.isHeightCompact(getWindowSizeClass(getResources()))) { ViewUtil.setBottomMargin(bottomActionBar, ViewUtil.getNavigationBarHeight(bottomActionBar)); } @@ -570,6 +611,7 @@ public class ConversationListFragment extends MainFragment implements Conversati boolean unreadOnly = conversationFilterRequest != null && conversationFilterRequest.getFilter() == ConversationFilter.UNREAD; builder.setQuery(state.getQuery()); + builder.setSearchFilter(state.getSearchFilter()); builder.addSection(new ContactSearchConfiguration.Section.Chats( unreadOnly, true, @@ -714,6 +756,46 @@ public class ConversationListFragment extends MainFragment implements Conversati ); } + private void initializeSearchFilterListener() { + getParentFragmentManager().setFragmentResultListener( + SearchFilterBottomSheet.REQUEST_KEY, + getViewLifecycleOwner(), + (requestKey, result) -> { + String action = result.getString(SearchFilterBottomSheet.RESULT_ACTION, ""); + + switch (action) { + case SearchFilterBottomSheet.ACTION_APPLY: + long startDate = result.getLong(SearchFilterBottomSheet.RESULT_START_DATE, -1); + long endDate = result.getLong(SearchFilterBottomSheet.RESULT_END_DATE, -1); + String authorIdStr = result.getString(SearchFilterBottomSheet.RESULT_AUTHOR_ID); + + activeSearchFilter = new SearchFilter( + startDate != -1 ? startDate : null, + endDate != -1 ? endDate : null, + authorIdStr != null ? RecipientId.from(Long.parseLong(authorIdStr)) : null + ); + mainToolbarViewModel.setHasActiveSearchFilter(!activeSearchFilter.isEmpty()); + contactSearchMediator.onSearchFilterChanged(activeSearchFilter); + break; + + case SearchFilterBottomSheet.ACTION_CLEAR: + activeSearchFilter = SearchFilter.EMPTY; + mainToolbarViewModel.setHasActiveSearchFilter(false); + contactSearchMediator.onSearchFilterChanged(activeSearchFilter); + break; + + case SearchFilterBottomSheet.ACTION_SELECT_AUTHOR: + searchFilterContactPickerLauncher.launch(new Intent(requireContext(), org.thoughtcrime.securesms.search.SingleContactSelectionActivity.class) + .putExtra(ContactSelectionArguments.DISPLAY_MODE, ContactSelectionDisplayMode.FLAG_PUSH | ContactSelectionDisplayMode.FLAG_ACTIVE_GROUPS) + .putExtra(EXTRA_FILTER_START_DATE, result.getLong(SearchFilterBottomSheet.RESULT_START_DATE, -1)) + .putExtra(EXTRA_FILTER_END_DATE, result.getLong(SearchFilterBottomSheet.RESULT_END_DATE, -1)) + .putExtra(EXTRA_FILTER_AUTHOR_ID, result.getString(SearchFilterBottomSheet.RESULT_AUTHOR_ID))); + break; + } + } + ); + } + private void updateSearchToolbarHint(@NonNull ConversationFilterRequest conversationFilterRequest) { mainToolbarViewModel.setSearchHint( conversationFilterRequest.getFilter() == ConversationFilter.OFF ? R.string.SearchToolbar_search : R.string.SearchToolbar_search_unread_chats @@ -1663,6 +1745,15 @@ public class ConversationListFragment extends MainFragment implements Conversati startActivity(AppSettingsActivity.chatFolders(requireContext())); } + public void showSearchFilterBottomSheet() { + SearchFilterBottomSheet.show( + getParentFragmentManager(), + activeSearchFilter.getStartDate(), + activeSearchFilter.getEndDate(), + activeSearchFilter.getAuthor() + ); + } + private void onSearchOpen() { chatListBackHandler.setEnabled(true); } @@ -1672,6 +1763,10 @@ public class ConversationListFragment extends MainFragment implements Conversati setAdapter(defaultAdapter); } + activeSearchFilter = SearchFilter.EMPTY; + mainToolbarViewModel.setHasActiveSearchFilter(false); + contactSearchMediator.onSearchFilterChanged(activeSearchFilter); + chatListBackHandler.setEnabled(false); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/SearchTable.kt b/app/src/main/java/org/thoughtcrime/securesms/database/SearchTable.kt index 91a57ad7c5..77857f07ab 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/SearchTable.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/SearchTable.kt @@ -130,6 +130,43 @@ class SearchTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTa } } + fun queryMessages(query: String, filter: org.thoughtcrime.securesms.search.SearchFilter): Cursor? { + if (filter.isEmpty) { + return queryMessages(query) + } + + val fullTextSearchQuery = createFullTextSearchQuery(query) + if (fullTextSearchQuery.isEmpty()) { + return null + } + + val extraConditions = StringBuilder() + val args = mutableListOf(fullTextSearchQuery) + + if (filter.startDate != null) { + extraConditions.append(" AND ${MessageTable.TABLE_NAME}.${MessageTable.DATE_RECEIVED} >= ?") + args.add(filter.startDate.toString()) + } + + if (filter.endDate != null) { + extraConditions.append(" AND ${MessageTable.TABLE_NAME}.${MessageTable.DATE_RECEIVED} <= ?") + args.add(filter.endDate.toString()) + } + + if (filter.author != null) { + extraConditions.append(" AND ${MessageTable.TABLE_NAME}.${MessageTable.FROM_RECIPIENT_ID} = ?") + args.add(filter.author.serialize()) + } + + @Language("sql") + val filteredQuery = MESSAGES_QUERY.replace( + "ORDER BY ${MessageTable.DATE_RECEIVED} DESC", + "$extraConditions ORDER BY ${MessageTable.DATE_RECEIVED} DESC" + ) + + return readableDatabase.rawQuery(filteredQuery, args.toTypedArray()) + } + fun queryMessages(query: String, threadId: Long): Cursor? { val fullTextSearchQuery = createFullTextSearchQuery(query) return if (TextUtils.isEmpty(fullTextSearchQuery)) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/LabsValues.kt b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/LabsValues.kt index 94bcb396bd..f2f4c79517 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/LabsValues.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/LabsValues.kt @@ -8,6 +8,7 @@ class LabsValues internal constructor(store: KeyValueStore) : SignalStoreValues( const val STORY_ARCHIVE: String = "labs.story_archive" const val INCOGNITO: String = "labs.incognito" const val GROUP_SUGGESTIONS_FOR_MEMBERS: String = "labs.group_suggestions_for_members" + const val BETTER_SEARCH: String = "labs.better_search" } public override fun onFirstEverAppLaunch() = Unit @@ -22,6 +23,8 @@ class LabsValues internal constructor(store: KeyValueStore) : SignalStoreValues( var groupSuggestionsForMembers by booleanValue(GROUP_SUGGESTIONS_FOR_MEMBERS, true).falseForExternalUsers() + var betterSearch by booleanValue(BETTER_SEARCH, true).falseForExternalUsers() + private fun SignalStoreValueDelegate.falseForExternalUsers(): SignalStoreValueDelegate { return this.map { actualValue -> RemoteConfig.internalUser && actualValue } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/main/MainToolbar.kt b/app/src/main/java/org/thoughtcrime/securesms/main/MainToolbar.kt index a78a565093..7ed6306ff2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/main/MainToolbar.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/main/MainToolbar.kt @@ -22,6 +22,7 @@ import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -105,6 +106,7 @@ interface MainToolbarCallback { fun onCloseArchiveClick() fun onCloseActionModeClick() fun onSearchQueryUpdated(query: String) + fun onSearchFilterClick() fun onNotificationProfileTooltipDismissed() object Empty : MainToolbarCallback { @@ -127,6 +129,7 @@ interface MainToolbarCallback { override fun onCloseArchiveClick() = Unit override fun onCloseActionModeClick() = Unit override fun onSearchQueryUpdated(query: String) = Unit + override fun onSearchFilterClick() = Unit override fun onNotificationProfileTooltipDismissed() = Unit } } @@ -164,6 +167,7 @@ data class MainToolbarState( val proxyState: ProxyState = ProxyState.NONE, @StringRes val searchHint: Int = R.string.SearchToolbar_search, val searchQuery: String = "", + val hasActiveSearchFilter: Boolean = false, val actionModeCount: Int = 0 ) { enum class ProxyState(@DrawableRes val icon: Int) { @@ -264,21 +268,41 @@ private fun SearchToolbar( ) } }, - trailingIcon = if (state.searchQuery.isNotEmpty()) { - { - IconButtons.IconButton( - onClick = { - callback.onSearchQueryUpdated("") + trailingIcon = { + Row { + if (SignalStore.labs.betterSearch) { + Box(contentAlignment = Alignment.TopEnd) { + IconButtons.IconButton( + onClick = callback::onSearchFilterClick + ) { + Icon( + imageVector = ImageVector.vectorResource(R.drawable.symbol_filter_24), + contentDescription = stringResource(R.string.MainToolbar__search_filter_content_description) + ) + } + if (state.hasActiveSearchFilter) { + Box( + modifier = Modifier + .padding(top = 8.dp, end = 8.dp) + .size(8.dp) + .background(color = MaterialTheme.colorScheme.primary, shape = CircleShape) + ) + } + } + } + if (state.searchQuery.isNotEmpty()) { + IconButtons.IconButton( + onClick = { + callback.onSearchQueryUpdated("") + } + ) { + Icon( + imageVector = ImageVector.vectorResource(R.drawable.ic_x_20), + contentDescription = stringResource(R.string.MainToolbar__clear_search_content_description) + ) } - ) { - Icon( - imageVector = ImageVector.vectorResource(R.drawable.ic_x_20), - contentDescription = stringResource(R.string.MainToolbar__clear_search_content_description) - ) } } - } else { - null }, contentPadding = PaddingValues(0.dp), colors = TextFieldDefaults.colors( diff --git a/app/src/main/java/org/thoughtcrime/securesms/main/MainToolbarViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/main/MainToolbarViewModel.kt index c0ed38f613..0f1b141678 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/main/MainToolbarViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/main/MainToolbarViewModel.kt @@ -213,6 +213,12 @@ class MainToolbarViewModel : ViewModel() { } } + fun setHasActiveSearchFilter(hasActiveSearchFilter: Boolean) { + internalStateFlow.update { + it.copy(hasActiveSearchFilter = hasActiveSearchFilter) + } + } + private fun emitPossibleSearchStateChangeEvent(previousMode: MainToolbarMode, mode: MainToolbarMode) { if (previousMode == MainToolbarMode.SEARCH && mode != MainToolbarMode.SEARCH) { emitEvent(Event.Search.Close) diff --git a/app/src/main/java/org/thoughtcrime/securesms/search/SearchFilter.kt b/app/src/main/java/org/thoughtcrime/securesms/search/SearchFilter.kt new file mode 100644 index 0000000000..5e90046ad3 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/search/SearchFilter.kt @@ -0,0 +1,17 @@ +package org.thoughtcrime.securesms.search + +import org.thoughtcrime.securesms.recipients.RecipientId + +data class SearchFilter( + val startDate: Long? = null, + val endDate: Long? = null, + val author: RecipientId? = null +) { + val isEmpty: Boolean + get() = startDate == null && endDate == null && author == null + + companion object { + @JvmField + val EMPTY = SearchFilter() + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/search/SearchFilterBottomSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/search/SearchFilterBottomSheet.kt new file mode 100644 index 0000000000..fa12e88ebb --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/search/SearchFilterBottomSheet.kt @@ -0,0 +1,270 @@ +package org.thoughtcrime.securesms.search + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.unit.dp +import androidx.core.os.bundleOf +import androidx.fragment.app.FragmentManager +import androidx.fragment.app.setFragmentResult +import com.google.android.material.datepicker.MaterialDatePicker +import org.signal.core.ui.BottomSheetUtil +import org.signal.core.ui.compose.BottomSheets +import org.signal.core.ui.compose.Buttons +import org.signal.core.ui.compose.ComposeBottomSheetDialogFragment +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.recipients.RecipientId +import org.thoughtcrime.securesms.util.DateUtils +import java.util.Locale +import kotlin.time.Duration.Companion.days + +/** + * A bottom sheet that allows you to aly additional filters to message search. + */ +class SearchFilterBottomSheet : ComposeBottomSheetDialogFragment() { + + override val peekHeightPercentage: Float = 0.66f + + companion object { + const val REQUEST_KEY = "search_filter_result" + const val RESULT_ACTION = "action" + const val RESULT_START_DATE = "start_date" + const val RESULT_END_DATE = "end_date" + const val RESULT_AUTHOR_ID = "author_id" + + const val ACTION_APPLY = "apply" + const val ACTION_CLEAR = "clear" + const val ACTION_SELECT_AUTHOR = "select_author" + + private const val ARG_START_DATE = "arg_start_date" + private const val ARG_END_DATE = "arg_end_date" + private const val ARG_AUTHOR_ID = "arg_author_id" + + @JvmStatic + fun show(fragmentManager: FragmentManager, startDate: Long?, endDate: Long?, authorId: RecipientId?) { + val fragment = SearchFilterBottomSheet().apply { + arguments = bundleOf( + ARG_START_DATE to (startDate ?: -1L), + ARG_END_DATE to (endDate ?: -1L), + ARG_AUTHOR_ID to authorId?.serialize() + ) + } + fragment.show(fragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG) + } + } + + @Composable + override fun SheetContent() { + val context = LocalContext.current + val args = requireArguments() + + var startDate by remember { + val v = args.getLong(ARG_START_DATE, -1L) + mutableStateOf(if (v == -1L) null else v) + } + var endDate by remember { + val v = args.getLong(ARG_END_DATE, -1L) + mutableStateOf(if (v == -1L) null else v) + } + val authorIdRaw = remember { args.getString(ARG_AUTHOR_ID) } + var authorId by remember { mutableStateOf(authorIdRaw?.let { RecipientId.from(it.toLong()) }) } + + val authorName = remember(authorId) { + authorId?.let { + Recipient.resolved(it).getDisplayName(context) + } + } + + val startDateText = remember(startDate) { + startDate?.let { DateUtils.getDayPrecisionTimeString(context, Locale.getDefault(), it) } + ?: context.getString(R.string.SearchFilterBottomSheet__not_set) + } + + val endDateText = remember(endDate) { + endDate?.let { DateUtils.getDayPrecisionTimeString(context, Locale.getDefault(), it) } + ?: context.getString(R.string.SearchFilterBottomSheet__not_set) + } + + SearchFilterSheetContent( + startDateText = startDateText, + endDateText = endDateText, + authorName = authorName ?: stringResource(R.string.SearchFilterBottomSheet__anyone), + onStartDateClick = { + val datePicker = MaterialDatePicker.Builder.datePicker() + .setTitleText(context.getString(R.string.SearchFilterBottomSheet__select_date)) + .apply { + startDate?.let { setSelection(it) } + } + .build() + + datePicker.addOnPositiveButtonClickListener { selection -> + startDate = selection + } + datePicker.show(childFragmentManager, "START_DATE_PICKER") + }, + onEndDateClick = { + val datePicker = MaterialDatePicker.Builder.datePicker() + .setTitleText(context.getString(R.string.SearchFilterBottomSheet__select_date)) + .apply { + endDate?.let { setSelection(it) } + } + .build() + + datePicker.addOnPositiveButtonClickListener { selection -> + // Set to end of the selected day + endDate = selection + 1.days.inWholeMilliseconds - 1 + } + datePicker.show(childFragmentManager, "END_DATE_PICKER") + }, + onAuthorClick = { + setFragmentResult( + REQUEST_KEY, + bundleOf( + RESULT_ACTION to ACTION_SELECT_AUTHOR, + RESULT_START_DATE to (startDate ?: -1L), + RESULT_END_DATE to (endDate ?: -1L), + RESULT_AUTHOR_ID to authorId?.serialize() + ) + ) + dismissAllowingStateLoss() + }, + onApplyClick = { + setFragmentResult( + REQUEST_KEY, + bundleOf( + RESULT_ACTION to ACTION_APPLY, + RESULT_START_DATE to (startDate ?: -1L), + RESULT_END_DATE to (endDate ?: -1L), + RESULT_AUTHOR_ID to authorId?.serialize() + ) + ) + dismissAllowingStateLoss() + }, + onClearClick = { + setFragmentResult( + REQUEST_KEY, + bundleOf( + RESULT_ACTION to ACTION_CLEAR + ) + ) + dismissAllowingStateLoss() + } + ) + } +} + +@Composable +private fun SearchFilterSheetContent( + startDateText: String, + endDateText: String, + authorName: String, + onStartDateClick: () -> Unit, + onEndDateClick: () -> Unit, + onAuthorClick: () -> Unit, + onApplyClick: () -> Unit, + onClearClick: () -> Unit +) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.fillMaxWidth() + ) { + BottomSheets.Handle() + + Text( + text = stringResource(R.string.SearchFilterBottomSheet__filter_search), + style = MaterialTheme.typography.titleLarge, + modifier = Modifier.padding(top = 18.dp, bottom = 24.dp) + ) + + FilterRow( + label = stringResource(R.string.SearchFilterBottomSheet__start_date), + value = startDateText, + onClick = onStartDateClick + ) + + FilterRow( + label = stringResource(R.string.SearchFilterBottomSheet__end_date), + value = endDateText, + onClick = onEndDateClick + ) + + FilterRow( + label = stringResource(R.string.SearchFilterBottomSheet__author), + value = authorName, + onClick = onAuthorClick + ) + + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.End), + modifier = Modifier + .fillMaxWidth() + .padding(start = 18.dp, end = 18.dp, top = 24.dp, bottom = 24.dp) + ) { + Buttons.MediumTonal( + onClick = onClearClick + ) { + Text(stringResource(R.string.SearchFilterBottomSheet__clear)) + } + + Buttons.MediumTonal( + onClick = onApplyClick + ) { + Text(stringResource(R.string.SearchFilterBottomSheet__apply)) + } + } + } +} + +@Composable +private fun FilterRow( + label: String, + value: String, + onClick: () -> Unit +) { + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 24.dp, vertical = 16.dp) + ) { + Text( + text = label, + style = MaterialTheme.typography.bodyLarge + ) + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = value, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Icon( + imageVector = ImageVector.vectorResource(R.drawable.ic_expand_down_24), + contentDescription = null, + modifier = Modifier + .padding(start = 8.dp) + .size(24.dp) + ) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/search/SearchRepository.java b/app/src/main/java/org/thoughtcrime/securesms/search/SearchRepository.java index 88526f7225..57b636b5a3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/search/SearchRepository.java +++ b/app/src/main/java/org/thoughtcrime/securesms/search/SearchRepository.java @@ -91,12 +91,13 @@ public class SearchRepository { } @WorkerThread - public @NonNull MessageSearchResult queryMessagesSync(@NonNull String query) { + public @NonNull MessageSearchResult queryMessagesSync(@NonNull String query, @NonNull SearchFilter filter) { long start = System.currentTimeMillis(); - List messages = queryMessages(query); - List mentionMessages = queryMentions(convertMentionsQueryToTokens(query)); - List combined = mergeMessagesAndMentions(messages, mentionMessages); + List messages = queryMessages(query, filter); + List mentionMessages = queryMentions(convertMentionsQueryToTokens(query)); + List filteredMentions = filterMentionResults(mentionMessages, filter); + List combined = mergeMessagesAndMentions(messages, filteredMentions); Log.d(TAG, "[messages] Search took " + (System.currentTimeMillis() - start) + " ms"); @@ -161,13 +162,13 @@ public class SearchRepository { } } - private @NonNull List queryMessages(@NonNull String query) { + private @NonNull List queryMessages(@NonNull String query, @NonNull SearchFilter filter) { if (Util.isEmpty(query)) { return Collections.emptyList(); } List results; - try (Cursor cursor = searchDatabase.queryMessages(query)) { + try (Cursor cursor = searchDatabase.queryMessages(query, filter)) { results = readToList(cursor, new MessageModelBuilder()); } @@ -216,7 +217,6 @@ public class SearchRepository { MessageStyler.style(result.getReceivedTimestampMs(), BodyRangeUtil.adjustBodyRanges(ranges, bodyAdjustments), (Spannable) updatedBody); updatedSnippet = SpannableString.valueOf(updatedSnippet); - //noinspection ConstantConditions updateSnippetWithStyles(result.getReceivedTimestampMs(), updatedBody, (SpannableString) updatedSnippet, BodyRangeUtil.adjustBodyRanges(ranges, snippetAdjustments)); } @@ -411,6 +411,28 @@ public class SearchRepository { } } + private static @NonNull List filterMentionResults(@NonNull List mentions, @NonNull SearchFilter filter) { + if (filter.isEmpty()) { + return mentions; + } + + List filtered = new ArrayList<>(); + for (MessageResult mention : mentions) { + if (filter.getStartDate() != null && mention.getReceivedTimestampMs() < filter.getStartDate()) { + continue; + } + if (filter.getEndDate() != null && mention.getReceivedTimestampMs() > filter.getEndDate()) { + continue; + } + if (filter.getAuthor() != null && !mention.getMessageRecipient().getId().equals(filter.getAuthor())) { + continue; + } + filtered.add(mention); + } + + return filtered; + } + private static @NonNull List mergeMessagesAndMentions(@NonNull List messages, @NonNull List mentionMessages) { Set includedMmsMessages = new HashSet<>(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/search/SingleContactSelectionActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/search/SingleContactSelectionActivity.kt new file mode 100644 index 0000000000..9834f0cf72 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/search/SingleContactSelectionActivity.kt @@ -0,0 +1,42 @@ +package org.thoughtcrime.securesms.search + +import android.content.Intent +import android.os.Bundle +import android.view.View +import org.thoughtcrime.securesms.ContactSelectionActivity +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.contacts.paged.ChatType +import org.thoughtcrime.securesms.recipients.RecipientId +import java.util.Optional +import java.util.function.Consumer + +/** + * A single-select contact picker that returns immediately when a contact is tapped. + */ +class SingleContactSelectionActivity : ContactSelectionActivity() { + override fun onCreate(icicle: Bundle?, ready: Boolean) { + super.onCreate(icicle, ready) + toolbar.setNavigationIcon(R.drawable.ic_arrow_left_24) + toolbar.setNavigationOnClickListener { _: View? -> + setResult(RESULT_CANCELED, Intent(intent)) + finish() + } + } + + override fun onBeforeContactSelected(isFromUnknownSearchKey: Boolean, recipientId: Optional, number: String?, chatType: Optional, callback: Consumer) { + callback.accept(true) + if (recipientId.isPresent) { + val result = Intent(intent) + result.putParcelableArrayListExtra(KEY_SELECTED_RECIPIENT, ArrayList(mutableListOf(recipientId.get()))) + setResult(RESULT_OK, result) + finish() + } + } + + override fun onSelectionChanged() { + } + + companion object { + const val KEY_SELECTED_RECIPIENT: String = "selected_recipient" + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index bfe1927383..baf1e91d01 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -9341,6 +9341,27 @@ Notification Profile Proxy + + Search filter + + + Filter search + + Start date + + End date + + Author + + Not set + + Anyone + + Apply + + Clear + + Select date Navigate up