From 963a72a66039c51a8127e67d81ecfe7f1c6df471 Mon Sep 17 00:00:00 2001 From: jeffrey-signal Date: Mon, 27 Oct 2025 17:58:19 -0400 Subject: [PATCH] Convert ContactFilterView to compose. --- .../components/ContactFilterView.java | 5 +- .../compose/ProvideIncognitoKeyboard.kt | 46 +++++ .../securesms/conversation/RecipientPicker.kt | 93 ++++------ .../conversation/RecipientSearchBar.kt | 170 ++++++++++++++++++ .../securesms/util/EditTextExtensions.kt | 5 +- .../main/res/layout/contact_filter_view.xml | 2 +- app/src/main/res/values/strings.xml | 10 +- 7 files changed, 262 insertions(+), 69 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/compose/ProvideIncognitoKeyboard.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/conversation/RecipientSearchBar.kt diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/ContactFilterView.java b/app/src/main/java/org/thoughtcrime/securesms/components/ContactFilterView.java index f8819b26a6..5f60592e27 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/ContactFilterView.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/ContactFilterView.java @@ -27,9 +27,10 @@ import org.thoughtcrime.securesms.util.ViewUtil; /** * A search input field for finding recipients. - *

- * In compose, use RecipientSearchField instead. + * + * @deprecated Use the RecipientSearchBar composable instead. */ +@Deprecated public final class ContactFilterView extends FrameLayout { private OnFilterChangedListener listener; diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/compose/ProvideIncognitoKeyboard.kt b/app/src/main/java/org/thoughtcrime/securesms/components/compose/ProvideIncognitoKeyboard.kt new file mode 100644 index 0000000000..45640095a3 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/compose/ProvideIncognitoKeyboard.kt @@ -0,0 +1,46 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.compose + +import android.os.Build +import android.view.inputmethod.EditorInfo +import androidx.compose.runtime.Composable +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.platform.InterceptPlatformTextInput +import androidx.compose.ui.platform.PlatformTextInputMethodRequest + +/** + * When [enabled]=true, this function sets the [EditorInfo.IME_FLAG_NO_PERSONALIZED_LEARNING] flag for all text fields within its content to enable the + * incognito keyboard. + * + * This workaround is needed until it's possible to configure granular IME options for a [TextField]. + * https://issuetracker.google.com/issues/359257538 + */ +@OptIn(ExperimentalComposeUiApi::class) +@Composable +fun ProvideIncognitoKeyboard( + enabled: Boolean, + content: @Composable () -> Unit +) { + if (enabled) { + InterceptPlatformTextInput( + interceptor = { request, nextHandler -> + val modifiedRequest = PlatformTextInputMethodRequest { outAttributes -> + request.createInputConnection(outAttributes).also { + if (Build.VERSION.SDK_INT >= 26) { + outAttributes.imeOptions = outAttributes.imeOptions or EditorInfo.IME_FLAG_NO_PERSONALIZED_LEARNING + } + } + } + nextHandler.startInputMethod(modifiedRequest) + } + ) { + content() + } + } else { + content() + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/RecipientPicker.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/RecipientPicker.kt index 52080f68ad..22366a3b4f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/RecipientPicker.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/RecipientPicker.kt @@ -7,13 +7,14 @@ package org.thoughtcrime.securesms.conversation import android.view.View import android.view.ViewGroup -import androidx.annotation.StringRes import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.isImeVisible import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -22,12 +23,13 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.platform.LocalConfiguration -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import androidx.compose.ui.viewinterop.AndroidView import androidx.fragment.compose.rememberFragmentState import androidx.recyclerview.widget.RecyclerView import kotlinx.coroutines.CoroutineScope @@ -40,7 +42,6 @@ import org.signal.core.util.DimensionUnit import org.signal.core.util.orNull import org.thoughtcrime.securesms.ContactSelectionListFragment import org.thoughtcrime.securesms.R -import org.thoughtcrime.securesms.components.ContactFilterView import org.thoughtcrime.securesms.components.menu.ActionItem import org.thoughtcrime.securesms.components.menu.SignalContextMenu import org.thoughtcrime.securesms.contacts.ContactSelectionDisplayMode @@ -54,13 +55,14 @@ import org.thoughtcrime.securesms.groups.SelectionLimits import org.thoughtcrime.securesms.recipients.PhoneNumber import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.recipients.RecipientId -import org.thoughtcrime.securesms.util.ViewUtil import java.util.Optional import java.util.function.Consumer /** * Provides a recipient search and selection UI. */ +@Suppress("KotlinConstantConditions") +@OptIn(ExperimentalLayoutApi::class) @Composable fun RecipientPicker( searchQuery: String, @@ -78,11 +80,26 @@ fun RecipientPicker( Column( modifier = modifier ) { - RecipientSearchField( - searchQuery = searchQuery, - onFilterChanged = { filter -> callbacks.listActions.onSearchQueryChanged(query = filter) }, - focusAndShowKeyboard = focusAndShowKeyboard, + val focusRequester = remember { FocusRequester() } + var shouldRequestFocus by rememberSaveable { mutableStateOf(focusAndShowKeyboard) } + + LaunchedEffect(Unit) { + if (shouldRequestFocus) { + focusRequester.requestFocus() + } + } + + val isImeVisible = WindowInsets.isImeVisible + LaunchedEffect(isImeVisible) { + shouldRequestFocus = isImeVisible + } + + RecipientSearchBar( + query = searchQuery, + onQueryChange = { filter -> callbacks.listActions.onSearchQueryChanged(query = filter) }, + onSearch = {}, modifier = Modifier + .focusRequester(focusRequester) .fillMaxWidth() .padding(horizontal = 16.dp) ) @@ -104,54 +121,7 @@ fun RecipientPicker( } } -/** - * A search input field for finding recipients. - * - * Intended to be a compose-based replacement for [ContactFilterView]. - */ -@Composable -private fun RecipientSearchField( - searchQuery: String, - onFilterChanged: (String) -> Unit, - @StringRes hintText: Int? = null, - focusAndShowKeyboard: Boolean = false, - modifier: Modifier = Modifier -) { - val context = LocalContext.current - val wrappedView = remember { - ContactFilterView(context, null, 0).apply { - hintText?.let { setHint(it) } - } - } - - LaunchedEffect(searchQuery) { - wrappedView.setText(searchQuery) - } - - // TODO [jeff] This causes the keyboard to re-open on rotation, which doesn't match the existing behavior of ContactFilterView. To fix this, - // RecipientSearchField needs to be converted to compose so we can use FocusRequestor. - LaunchedEffect(focusAndShowKeyboard) { - if (focusAndShowKeyboard) { - wrappedView.focusAndShowKeyboard() - } else { - wrappedView.clearFocus() - ViewUtil.hideKeyboard(wrappedView.context, wrappedView) - } - } - - DisposableEffect(onFilterChanged) { - wrappedView.setOnFilterChangedListener { filter -> onFilterChanged(filter) } - onDispose { - wrappedView.setOnFilterChangedListener(null) - } - } - - AndroidView( - factory = { wrappedView }, - modifier = modifier - ) -} - +@Suppress("KotlinConstantConditions") @Composable private fun RecipientSearchResultsList( displayModes: Set, @@ -179,6 +149,7 @@ private fun RecipientSearchResultsList( val fragmentState = rememberFragmentState() var currentFragment by remember { mutableStateOf(null) } val coroutineScope = rememberCoroutineScope() + val focusManager = LocalFocusManager.current Fragments.Fragment( arguments = fragmentArgs, @@ -188,6 +159,7 @@ private fun RecipientSearchResultsList( fragment.view?.setPadding(0, 0, 0, 0) fragment.setUpCallbacks( callbacks = callbacks, + clearFocus = { focusManager.clearFocus() }, coroutineScope = coroutineScope ) }, @@ -241,6 +213,7 @@ private fun RecipientSearchResultsList( private fun ContactSelectionListFragment.setUpCallbacks( callbacks: RecipientPickerCallbacks, + clearFocus: () -> Unit, coroutineScope: CoroutineScope ) { val fragment: ContactSelectionListFragment = this @@ -302,9 +275,7 @@ private fun ContactSelectionListFragment.setUpCallbacks( } fragment.setOnRefreshListener { callbacks.refresh?.onRefresh() } - fragment.setScrollCallback { - fragment.view?.let { view -> ViewUtil.hideKeyboard(view.context, view) } - } + fragment.setScrollCallback { clearFocus() } } private suspend fun showItemContextMenu( diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/RecipientSearchBar.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/RecipientSearchBar.kt new file mode 100644 index 0000000000..950494a5da --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/RecipientSearchBar.kt @@ -0,0 +1,170 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.conversation + +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SearchBar +import androidx.compose.material3.SearchBarDefaults +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.material3.rememberSearchBarState +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.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +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.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import org.signal.core.ui.compose.DayNightPreviews +import org.signal.core.ui.compose.IconButtons.IconButton +import org.signal.core.ui.compose.theme.SignalTheme +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.components.compose.ProvideIncognitoKeyboard +import org.thoughtcrime.securesms.util.TextSecurePreferences + +/** + * A search input field for finding recipients. + * + * Replaces [org.thoughtcrime.securesms.components.ContactFilterView]. + */ +@OptIn(ExperimentalMaterial3Api::class, ExperimentalComposeUiApi::class, ExperimentalComposeUiApi::class) +@Composable +fun RecipientSearchBar( + hint: String = stringResource(R.string.RecipientSearchBar__search_name_or_number), + query: String, + onQueryChange: (String) -> Unit, + onSearch: (String) -> Unit, + modifier: Modifier = Modifier +) { + val state = rememberSearchBarState() + var keyboardOptions by remember { + mutableStateOf( + KeyboardOptions( + keyboardType = KeyboardType.Text, + imeAction = ImeAction.Search + ) + ) + } + + ProvideIncognitoKeyboard( + enabled = TextSecurePreferences.isIncognitoKeyboardEnabled(LocalContext.current) + ) { + SearchBar( + state = state, + inputField = { + TextField( + value = query, + onValueChange = onQueryChange, + placeholder = { Text(hint) }, + singleLine = true, + shape = SearchBarDefaults.inputFieldShape, + colors = TextFieldDefaults.colors( + unfocusedContainerColor = MaterialTheme.colorScheme.surfaceVariant, + focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant, + disabledContainerColor = MaterialTheme.colorScheme.surfaceVariant, + focusedIndicatorColor = Color.Transparent, + disabledIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent + ), + keyboardOptions = keyboardOptions, + keyboardActions = KeyboardActions( + onSearch = { onSearch(query) } + ), + trailingIcon = { + val modifier = Modifier.padding(end = 4.dp) + if (query.isNotEmpty()) { + ClearQueryButton( + onClearQuery = { onQueryChange("") }, + modifier = modifier + ) + } else { + KeyboardToggleButton( + keyboardType = keyboardOptions.keyboardType, + onKeyboardTypeChange = { keyboardOptions = keyboardOptions.copy(keyboardType = it) }, + modifier = modifier + ) + } + } + ) + }, + modifier = modifier + ) + } +} + +@Composable +private fun KeyboardToggleButton( + keyboardType: KeyboardType, + onKeyboardTypeChange: (KeyboardType) -> Unit = {}, + modifier: Modifier = Modifier +) { + IconButton( + onClick = { + onKeyboardTypeChange( + when (keyboardType) { + KeyboardType.Text -> KeyboardType.Phone + else -> KeyboardType.Text + } + ) + }, + modifier = modifier + ) { + when (keyboardType) { + KeyboardType.Text -> Icon( + imageVector = ImageVector.vectorResource(R.drawable.ic_number_pad_conversation_filter_24), + tint = MaterialTheme.colorScheme.onSurface, + contentDescription = stringResource(R.string.RecipientSearchBar_accessibility_switch_to_numeric_keyboard) + ) + + else -> Icon( + imageVector = ImageVector.vectorResource(R.drawable.ic_keyboard_24), + tint = MaterialTheme.colorScheme.onSurface, + contentDescription = stringResource(R.string.RecipientSearchBar_accessibility_switch_to_alphanumeric_keyboard) + ) + } + } +} + +@Composable +private fun ClearQueryButton( + onClearQuery: () -> Unit, + modifier: Modifier = Modifier +) { + IconButton( + onClick = { onClearQuery() }, + modifier = modifier + ) { + Icon( + imageVector = ImageVector.vectorResource(R.drawable.ic_x_conversation_filter_24), + tint = MaterialTheme.colorScheme.onSurface, + contentDescription = stringResource(R.string.RecipientSearchBar_accessibility_clear_search) + ) + } +} + +@Composable +@DayNightPreviews +private fun RecipientSearchBarPreview() = SignalTheme { + RecipientSearchBar( + query = "", + onQueryChange = {}, + onSearch = {} + ) +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/EditTextExtensions.kt b/app/src/main/java/org/thoughtcrime/securesms/util/EditTextExtensions.kt index c91968ccc1..34655f9f7a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/EditTextExtensions.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/EditTextExtensions.kt @@ -4,9 +4,8 @@ import android.widget.EditText import androidx.appcompat.widget.SearchView /** - * Since this value is only supported on API26+ we hard-code it here - * to avoid issues with older versions. This mirrors the approach - * taken by [org.thoughtcrime.securesms.components.ComposeText]. + * Since [android.view.inputmethod.EditorInfo.IME_FLAG_NO_PERSONALIZED_LEARNING] is only supported on API 26+ we hard-code it here + * to avoid issues with older versions. This mirrors the approach taken by [org.thoughtcrime.securesms.components.ComposeText]. */ private const val INCOGNITO_KEYBOARD = 16777216 diff --git a/app/src/main/res/layout/contact_filter_view.xml b/app/src/main/res/layout/contact_filter_view.xml index c985fe736d..23d5749be8 100644 --- a/app/src/main/res/layout/contact_filter_view.xml +++ b/app/src/main/res/layout/contact_filter_view.xml @@ -29,7 +29,7 @@ android:background="@android:color/transparent" android:fontFamily="sans-serif" android:gravity="center_vertical" - android:hint="@string/ContactFilterView__search_name_or_number" + android:hint="@string/RecipientSearchBar__search_name_or_number" android:inputType="textPersonName" android:lineSpacingExtra="6sp" android:textAppearance="@style/TextSecure.TitleTextStyle" diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a7d28fcf3b..5642cdb342 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -5931,8 +5931,14 @@ %1$s are not Signal users - - Name, username or number + + Name, username or number + + Switch to numeric keyboard + + Switch to alphanumeric keyboard + + Reset search filter ยท %1$s