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