diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsFragment.kt index a69cc0f9ae..8d29ccba4f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsFragment.kt @@ -6,9 +6,12 @@ import android.content.Context import android.content.DialogInterface import android.os.Build import android.os.Bundle +import android.view.MenuItem import android.view.View import android.widget.EditText import android.widget.Toast +import androidx.appcompat.widget.SearchView +import androidx.appcompat.widget.Toolbar import androidx.fragment.app.setFragmentResultListener import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.lifecycleScope @@ -74,8 +77,10 @@ import org.thoughtcrime.securesms.recipients.RecipientId import org.thoughtcrime.securesms.registration.data.QuickstartCredentialExporter import org.thoughtcrime.securesms.storage.StorageSyncHelper import org.thoughtcrime.securesms.util.ConversationUtil +import org.thoughtcrime.securesms.util.TextSecurePreferences import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter import org.thoughtcrime.securesms.util.navigation.safeNavigate +import org.thoughtcrime.securesms.util.setIncognitoKeyboardEnabled import org.whispersystems.signalservice.api.push.UsernameLinkComponents import java.util.Optional import java.util.UUID @@ -84,13 +89,14 @@ import kotlin.math.max import kotlin.random.Random import kotlin.time.Duration.Companion.milliseconds -class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__internal_preferences) { +class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__internal_preferences, R.menu.internal_settings) { companion object { private val TAG = Log.tag(InternalSettingsFragment::class.java) } private lateinit var viewModel: InternalSettingsViewModel + private var searchMenuItem: MenuItem? = null private var scrollToPosition: Int = 0 private val layoutManager: LinearLayoutManager? @@ -107,6 +113,7 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) scrollToPosition = SignalStore.internal.lastScrollPosition + initializeSearch(view) setFragmentResultListener(CallQualityBottomSheetFragment.REQUEST_KEY) { _, bundle -> if (bundle.getBoolean(CallQualityBottomSheetFragment.REQUEST_KEY, false)) { @@ -125,8 +132,11 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter viewModel = ViewModelProvider(this, factory)[InternalSettingsViewModel::class.java] viewModel.state.observe(viewLifecycleOwner) { - adapter.submitList(getConfiguration(it).toMappingModelList()) { - if (scrollToPosition != 0) { + val mappingModelList = getConfiguration(it).toMappingModelList() + val filteredList = viewModel.filterPreferences(requireContext(), mappingModelList, it.searchQuery) + + adapter.submitList(filteredList) { + if (scrollToPosition != 0 && it.searchQuery.isBlank()) { layoutManager?.scrollToPositionWithOffset(scrollToPosition, 0) scrollToPosition = 0 } @@ -134,6 +144,56 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter } } + override fun onToolbarNavigationClicked() { + if (searchMenuItem?.isActionViewExpanded == true) { + searchMenuItem?.collapseActionView() + } else { + super.onToolbarNavigationClicked() + } + } + + private fun initializeSearch(view: View) { + val toolbar: Toolbar = view.findViewById(R.id.toolbar) + searchMenuItem = toolbar.menu.findItem(R.id.menu_search) + + val searchView: SearchView = searchMenuItem?.actionView as? SearchView ?: return + val queryListener = object : SearchView.OnQueryTextListener { + override fun onQueryTextSubmit(query: String?): Boolean { + searchView.clearFocus() + viewModel.setSearchQuery(query.orEmpty()) + return true + } + + override fun onQueryTextChange(newText: String?): Boolean { + viewModel.setSearchQuery(newText.orEmpty()) + return true + } + } + + searchView.maxWidth = Integer.MAX_VALUE + searchView.queryHint = getString(R.string.CameraContacts__menu_search) + + searchMenuItem?.setOnActionExpandListener(object : MenuItem.OnActionExpandListener { + override fun onMenuItemActionExpand(item: MenuItem): Boolean { + searchView.setIncognitoKeyboardEnabled(TextSecurePreferences.isIncognitoKeyboardEnabled(requireContext())) + searchView.setOnQueryTextListener(queryListener) + return true + } + + override fun onMenuItemActionCollapse(item: MenuItem): Boolean { + searchView.setOnQueryTextListener(null) + searchView.setQuery("", false) + viewModel.setSearchQuery("") + return true + } + }) + + val currentQuery = viewModel.state.value?.searchQuery.orEmpty() + if (currentQuery.isNotBlank() && searchMenuItem?.expandActionView() == true) { + searchView.setQuery(currentQuery, false) + } + } + private fun getConfiguration(state: InternalSettingsState): DSLConfiguration { return configure { sectionHeaderPref(DSLSettingsText.from("Account")) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsState.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsState.kt index be038bcda4..0319dd5409 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsState.kt @@ -32,5 +32,6 @@ data class InternalSettingsState( val forceSplitPane: Boolean, val forceSinglePane: Boolean, val useNewMediaActivity: Boolean, - val disableInternalUser: Boolean + val disableInternalUser: Boolean, + val searchQuery: String = "" ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsViewModel.kt index 53ff99ef7f..3602aaf510 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsViewModel.kt @@ -1,10 +1,14 @@ package org.thoughtcrime.securesms.components.settings.app.internal +import android.content.Context import androidx.lifecycle.LiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import io.reactivex.rxjava3.core.Observable import org.signal.ringrtc.CallManager +import org.thoughtcrime.securesms.components.settings.DividerPreference +import org.thoughtcrime.securesms.components.settings.PreferenceModel +import org.thoughtcrime.securesms.components.settings.SectionHeaderPreference import org.thoughtcrime.securesms.database.model.RemoteMegaphoneRecord import org.thoughtcrime.securesms.jobs.StoryOnboardingDownloadJob import org.thoughtcrime.securesms.keyvalue.InternalValues @@ -12,7 +16,10 @@ import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.stories.Stories import org.thoughtcrime.securesms.util.RemoteConfig +import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel +import org.thoughtcrime.securesms.util.adapter.mapping.MappingModelList import org.thoughtcrime.securesms.util.livedata.Store +import java.util.Locale class InternalSettingsViewModel(private val repository: InternalSettingsRepository) : ViewModel() { private val preferenceDataStore = SignalStore.getPreferenceDataStore() @@ -167,7 +174,47 @@ class InternalSettingsViewModel(private val repository: InternalSettingsReposito } fun refresh() { - store.update { getState().copy(emojiVersion = it.emojiVersion) } + store.update { getState().copy(emojiVersion = it.emojiVersion, searchQuery = it.searchQuery) } + } + + fun setSearchQuery(query: String) { + store.update { + if (it.searchQuery == query) { + it + } else { + it.copy(searchQuery = query) + } + } + } + + fun filterPreferences(context: Context, items: MappingModelList, query: String): MappingModelList { + val normalizedQuery = query.trim().lowercase(Locale.getDefault()) + if (normalizedQuery.isBlank()) { + return items + } + + val groups = buildSearchGroups(items) + val filtered = MappingModelList() + + groups.forEach { group -> + val headerMatches = group.header?.searchableText(context)?.contains(normalizedQuery) == true + val matchingItems = if (headerMatches) { + group.items + } else { + group.items.filter { it.searchableText(context)?.contains(normalizedQuery) == true } + } + + if (headerMatches || matchingItems.isNotEmpty()) { + if (filtered.isNotEmpty() && group.divider != null) { + filtered.add(group.divider) + } + + group.header?.let { filtered.add(it) } + filtered.addAll(matchingItems) + } + } + + return filtered } private fun getState() = InternalSettingsState( @@ -225,6 +272,57 @@ class InternalSettingsViewModel(private val repository: InternalSettingsReposito refresh() } + private fun buildSearchGroups(items: MappingModelList): List { + val groups = mutableListOf() + var divider: DividerPreference? = null + var header: SectionHeaderPreference? = null + var groupItems = mutableListOf>() + + fun flush() { + if (header != null || groupItems.isNotEmpty()) { + groups.add(SearchGroup(divider, header, groupItems)) + } + + divider = null + header = null + groupItems = mutableListOf() + } + + items.forEach { item -> + when (item) { + is DividerPreference -> { + flush() + divider = item + } + is SectionHeaderPreference -> { + flush() + header = item + } + else -> groupItems.add(item) + } + } + + flush() + + return groups + } + + private fun MappingModel<*>.searchableText(context: Context): String? { + return if (this is PreferenceModel<*>) { + listOfNotNull(title, summary) + .joinToString(separator = " ") { it.resolve(context).toString() } + .lowercase(Locale.getDefault()) + } else { + null + } + } + + private data class SearchGroup( + val divider: DividerPreference?, + val header: SectionHeaderPreference?, + val items: List> + ) + class Factory(private val repository: InternalSettingsRepository) : ViewModelProvider.Factory { override fun create(modelClass: Class): T { return requireNotNull(modelClass.cast(InternalSettingsViewModel(repository))) diff --git a/app/src/main/res/menu/internal_settings.xml b/app/src/main/res/menu/internal_settings.xml new file mode 100644 index 0000000000..aad385402f --- /dev/null +++ b/app/src/main/res/menu/internal_settings.xml @@ -0,0 +1,13 @@ + + + + + +