Add the ability to filter search by date and author.

This commit is contained in:
Greyson Parrelli
2026-03-19 12:20:10 -04:00
committed by Cody Henthorne
parent 72cbe61f6c
commit 7253aaaa14
21 changed files with 612 additions and 26 deletions

View File

@@ -458,6 +458,13 @@
android:label="@string/AndroidManifest__select_contacts"
android:windowSoftInputMode="stateHidden" />
<activity
android:name=".search.SingleContactSelectionActivity"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
android:exported="false"
android:label="@string/AndroidManifest__select_contacts"
android:windowSoftInputMode="stateHidden" />
<activity
android:name=".giph.ui.GiphyActivity"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"

View File

@@ -1215,6 +1215,14 @@ class MainActivity :
toolbarViewModel.setSearchQuery(query)
}
override fun onSearchFilterClick() {
supportFragmentManager.fragments.forEach { fragment ->
if (fragment is ConversationListFragment) {
fragment.showSearchFilterBottomSheet()
}
}
}
override fun onNotificationProfileTooltipDismissed() {
SignalStore.notificationProfile.hasSeenTooltip = true
toolbarViewModel.setShowNotificationProfilesTooltip(false)

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<Section>,
val emptyStateSections: List<Section>
) {
@@ -335,6 +337,7 @@ class ContactSearchConfiguration private constructor(
private val sections: MutableList<Section> = 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()))

View File

@@ -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<ContactSearchKey>) {
Log.d(TAG, "setKeysSelected() Keys: ${keys.map { it.toString() }}")
viewModel.setKeysSelected(callbacks.onBeforeContactsSelected(null, keys))

View File

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

View File

@@ -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<ContactSearchConfiguration.SectionKey> = emptySet(),
val groupStories: Set<ContactSearchData.Story> = emptySet()
val groupStories: Set<ContactSearchData.Story> = emptySet(),
val searchFilter: SearchFilter = SearchFilter.EMPTY
)

View File

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

View File

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

View File

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

View File

@@ -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<Boolean>.falseForExternalUsers(): SignalStoreValueDelegate<Boolean> {
return this.map { actualValue -> RemoteConfig.internalUser && actualValue }
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<MessageResult> messages = queryMessages(query);
List<MessageResult> mentionMessages = queryMentions(convertMentionsQueryToTokens(query));
List<MessageResult> combined = mergeMessagesAndMentions(messages, mentionMessages);
List<MessageResult> messages = queryMessages(query, filter);
List<MessageResult> mentionMessages = queryMentions(convertMentionsQueryToTokens(query));
List<MessageResult> filteredMentions = filterMentionResults(mentionMessages, filter);
List<MessageResult> combined = mergeMessagesAndMentions(messages, filteredMentions);
Log.d(TAG, "[messages] Search took " + (System.currentTimeMillis() - start) + " ms");
@@ -161,13 +162,13 @@ public class SearchRepository {
}
}
private @NonNull List<MessageResult> queryMessages(@NonNull String query) {
private @NonNull List<MessageResult> queryMessages(@NonNull String query, @NonNull SearchFilter filter) {
if (Util.isEmpty(query)) {
return Collections.emptyList();
}
List<MessageResult> 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<MessageResult> filterMentionResults(@NonNull List<MessageResult> mentions, @NonNull SearchFilter filter) {
if (filter.isEmpty()) {
return mentions;
}
List<MessageResult> 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<MessageResult> mergeMessagesAndMentions(@NonNull List<MessageResult> messages, @NonNull List<MessageResult> mentionMessages) {
Set<Long> includedMmsMessages = new HashSet<>();

View File

@@ -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<RecipientId>, number: String?, chatType: Optional<ChatType>, callback: Consumer<Boolean>) {
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"
}
}

View File

@@ -9341,6 +9341,27 @@
<string name="MainToolbar__notification_profile_content_description">Notification Profile</string>
<!-- Accessibility label for more options button in MainToolbar -->
<string name="MainToolbar__proxy_content_description">Proxy</string>
<!-- Accessibility label for search filter button in MainToolbar -->
<string name="MainToolbar__search_filter_content_description" translatable="false">Search filter</string>
<!-- SearchFilterBottomSheet: Title -->
<string name="SearchFilterBottomSheet__filter_search" translatable="false">Filter search</string>
<!-- SearchFilterBottomSheet: Start date label -->
<string name="SearchFilterBottomSheet__start_date" translatable="false">Start date</string>
<!-- SearchFilterBottomSheet: End date label -->
<string name="SearchFilterBottomSheet__end_date" translatable="false">End date</string>
<!-- SearchFilterBottomSheet: Author label -->
<string name="SearchFilterBottomSheet__author" translatable="false">Author</string>
<!-- SearchFilterBottomSheet: Placeholder for unset date -->
<string name="SearchFilterBottomSheet__not_set" translatable="false">Not set</string>
<!-- SearchFilterBottomSheet: Placeholder for unset author -->
<string name="SearchFilterBottomSheet__anyone" translatable="false">Anyone</string>
<!-- SearchFilterBottomSheet: Apply button -->
<string name="SearchFilterBottomSheet__apply" translatable="false">Apply</string>
<!-- SearchFilterBottomSheet: Clear button -->
<string name="SearchFilterBottomSheet__clear" translatable="false">Clear</string>
<!-- SearchFilterBottomSheet: Select date dialog title -->
<string name="SearchFilterBottomSheet__select_date" translatable="false">Select date</string>
<!-- Accessibility label for a button displayed in the toolbar to return to the previous screen. -->
<string name="DefaultTopAppBar__navigate_up_content_description">Navigate up</string>