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