diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 52c8bd9d30..308803275e 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -1063,6 +1063,11 @@
android:theme="@style/Theme.Signal.DayNight.NoActionBar"
android:exported="false"/>
+
+
,
+ selectionLimits: SelectionLimits? = null
+ ): Intent {
+ return Intent(context, AddToGroupsActivityV2::class.java).apply {
+ putExtra(EXTRA_RECIPIENT_ID, recipientId)
+ putExtra(EXTRA_SELECTION_LIMITS, selectionLimits)
+ putParcelableArrayListExtra(EXTRA_PRESELECTED_GROUPS, ArrayList(existingGroupMemberships))
+ }
+ }
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) {
+ enableEdgeToEdge()
+ super.onCreate(savedInstanceState, ready)
+
+ val navigateBack = onBackPressedDispatcher::onBackPressed
+
+ setContent {
+ SignalTheme {
+ AddToGroupsScreen(
+ viewModel = viewModel {
+ AddToGroupsViewModelV2(
+ recipientId = intent.getParcelableExtraCompat(EXTRA_RECIPIENT_ID, RecipientId::class.java)!!,
+ selectionLimits = intent.getParcelableExtraCompat(EXTRA_SELECTION_LIMITS, SelectionLimits::class.java),
+ existingGroupMemberships = intent.getParcelableArrayListExtraCompat(EXTRA_PRESELECTED_GROUPS, RecipientId::class.java)!!.toSet()
+ )
+ },
+ closeScreen = navigateBack
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun AddToGroupsScreen(
+ viewModel: AddToGroupsViewModelV2,
+ closeScreen: () -> Unit
+) {
+ val callbacks = remember {
+ object : UiCallbacks {
+ override fun onSearchQueryChanged(query: String) = viewModel.onSearchQueryChanged(query)
+ override fun onSelectionChanged(newSelections: List, totalMembersCount: Int) = viewModel.selectGroups(newSelections)
+ override fun addToSelectedGroups() = viewModel.addToSelectedGroups()
+ override fun onAddConfirmed(groupRecipient: Recipient) = viewModel.addToGroups(listOf(groupRecipient))
+ override fun onUserMessageDismissed(userMessage: UserMessage) = viewModel.clearUserMessage()
+ override fun onBackPressed() = closeScreen()
+ }
+ }
+
+ val uiState by viewModel.uiState.collectAsStateWithLifecycle()
+
+ AddToGroupsScreenUi(
+ uiState = uiState,
+ callbacks = callbacks
+ )
+}
+
+@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3AdaptiveApi::class)
+@Composable
+private fun AddToGroupsScreenUi(
+ uiState: AddToGroupsUiState,
+ callbacks: UiCallbacks
+) {
+ val title = if (uiState.isMultiSelectEnabled) {
+ stringResource(R.string.AddToGroupActivity_add_to_groups)
+ } else {
+ stringResource(R.string.AddToGroupActivity_add_to_a_group)
+ }
+
+ RecipientPickerScaffold(
+ title = title,
+ forceSplitPane = uiState.forceSplitPane,
+ onNavigateUpClick = callbacks::onBackPressed,
+ topAppBarActions = {},
+ snackbarHostState = remember { SnackbarHostState() },
+ primaryContent = {
+ AddToGroupsRecipientPicker(
+ uiState = uiState,
+ callbacks = callbacks
+ )
+
+ UserMessagesHost(
+ userMessage = uiState.userMessage,
+ onAddConfirmed = { groupRecipient -> callbacks.onAddConfirmed(groupRecipient) },
+ onDismiss = callbacks::onUserMessageDismissed,
+ closeScreen = callbacks::onBackPressed
+ )
+ }
+ )
+}
+
+@Composable
+private fun AddToGroupsRecipientPicker(
+ uiState: AddToGroupsUiState,
+ callbacks: UiCallbacks,
+ modifier: Modifier = Modifier
+) {
+ Box(modifier = modifier) {
+ RecipientPicker(
+ searchQuery = uiState.searchQuery,
+ displayModes = setOf(RecipientPicker.DisplayMode.ACTIVE_GROUPS, RecipientPicker.DisplayMode.GROUPS_AFTER_CONTACTS),
+ selectionLimits = uiState.selectionLimits,
+ preselectedRecipients = uiState.existingGroupMemberships,
+ includeRecents = true,
+ isRefreshing = false,
+ listBottomPadding = 64.dp,
+ clipListToPadding = false,
+ callbacks = RecipientPickerCallbacks(
+ listActions = callbacks
+ ),
+ modifier = modifier.fillMaxSize()
+ )
+
+ if (uiState.isMultiSelectEnabled) {
+ Box(
+ modifier = Modifier
+ .align(Alignment.BottomEnd)
+ .padding(start = 16.dp, end = 16.dp, bottom = 16.dp)
+ ) {
+ Buttons.MediumTonal(
+ enabled = uiState.newSelections.isNotEmpty(),
+ onClick = callbacks::addToSelectedGroups
+ ) {
+ Text(text = stringResource(R.string.AddMembersActivity__done))
+ }
+ }
+ }
+ }
+}
+
+private interface UiCallbacks : RecipientPickerCallbacks.ListActions {
+ override suspend fun shouldAllowSelection(selection: RecipientSelection): Boolean = true
+ override fun onRecipientSelected(selection: RecipientSelection) = Unit
+ override fun onPendingRecipientSelectionsConsumed() = Unit
+ fun addToSelectedGroups()
+ fun onAddConfirmed(groupRecipient: Recipient)
+ fun onUserMessageDismissed(userMessage: UserMessage)
+ fun onBackPressed()
+
+ object Empty : UiCallbacks {
+ override fun onSearchQueryChanged(query: String) = Unit
+ override fun addToSelectedGroups() = Unit
+ override fun onAddConfirmed(groupRecipient: Recipient) = Unit
+ override fun onUserMessageDismissed(userMessage: UserMessage) = Unit
+ override fun onBackPressed() = Unit
+ }
+}
+
+@Composable
+private fun UserMessagesHost(
+ userMessage: UserMessage?,
+ onAddConfirmed: (Recipient) -> Unit,
+ onDismiss: (UserMessage) -> Unit,
+ closeScreen: () -> Unit
+) {
+ val context = LocalContext.current
+ when (userMessage) {
+ null -> {}
+
+ is UserMessage.ConfirmAddToGroup -> {
+ AddToGroupConfirmationDialog(
+ message = userMessage,
+ onAddConfirmed = onAddConfirmed,
+ onDismiss = onDismiss
+ )
+ }
+
+ is UserMessage.AddedRecipientToGroup -> {
+ val toastMessage = stringResource(
+ R.string.AddToGroupActivity_s_added_to_s,
+ userMessage.recipient.getDisplayName(context),
+ userMessage.targetGroup.getDisplayName(context)
+ )
+ Toast.makeText(context, toastMessage, Toast.LENGTH_SHORT).show()
+ onDismiss(userMessage)
+ closeScreen()
+ }
+
+ is UserMessage.CantAddRecipientToLegacyGroup -> {
+ Toast.makeText(context, stringResource(R.string.AddToGroupActivity_this_person_cant_be_added_to_legacy_groups), Toast.LENGTH_SHORT).show()
+ onDismiss(userMessage)
+ }
+
+ is UserMessage.GroupUpdateError -> {
+ val toastMessage = stringResource(GroupErrors.getUserDisplayMessage(userMessage.failureReason))
+ Toast.makeText(context, toastMessage, Toast.LENGTH_SHORT).show()
+ onDismiss(userMessage)
+ }
+ }
+}
+
+@Composable
+private fun AddToGroupConfirmationDialog(
+ message: UserMessage.ConfirmAddToGroup,
+ onAddConfirmed: (Recipient) -> Unit,
+ onDismiss: (UserMessage) -> Unit
+) {
+ val context = LocalContext.current
+ val bodyText: String = stringResource(
+ R.string.AddToGroupActivity_add_s_to_s,
+ message.recipientToAdd.getDisplayName(context),
+ message.targetGroup.getDisplayName(context)
+ )
+
+ Dialogs.SimpleAlertDialog(
+ title = stringResource(R.string.AddToGroupActivity_add_member),
+ body = bodyText,
+ confirm = stringResource(R.string.AddToGroupActivity_add),
+ dismiss = stringResource(android.R.string.cancel),
+ onConfirm = { onAddConfirmed(message.targetGroup) },
+ onDismiss = { onDismiss(message) }
+ )
+}
+
+@AllDevicePreviews
+@Composable
+private fun AddToSingleGroupScreenPreview() {
+ Previews.Preview {
+ AddToGroupsScreenUi(
+ uiState = AddToGroupsUiState(
+ forceSplitPane = false,
+ selectionLimits = null
+ ),
+ callbacks = UiCallbacks.Empty
+ )
+ }
+}
+
+@AllDevicePreviews
+@Composable
+private fun AddToMultipleGroupsScreenPreview() {
+ Previews.Preview {
+ AddToGroupsScreenUi(
+ uiState = AddToGroupsUiState(
+ forceSplitPane = false,
+ selectionLimits = SelectionLimits.NO_LIMITS
+ ),
+ callbacks = UiCallbacks.Empty
+ )
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/addtogroup/AddToGroupsViewModelV2.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/addtogroup/AddToGroupsViewModelV2.kt
new file mode 100644
index 0000000000..9614b38fbd
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/addtogroup/AddToGroupsViewModelV2.kt
@@ -0,0 +1,151 @@
+/*
+ * Copyright 2025 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.thoughtcrime.securesms.groups.ui.addtogroup
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import org.signal.core.util.logging.Log
+import org.thoughtcrime.securesms.contacts.SelectedContact
+import org.thoughtcrime.securesms.groups.SelectionLimits
+import org.thoughtcrime.securesms.groups.ui.GroupChangeFailureReason
+import org.thoughtcrime.securesms.groups.ui.addtogroup.AddToGroupsUiState.UserMessage
+import org.thoughtcrime.securesms.groups.v2.GroupAddMembersResult
+import org.thoughtcrime.securesms.groups.v2.GroupManagementRepository
+import org.thoughtcrime.securesms.keyvalue.SignalStore
+import org.thoughtcrime.securesms.recipients.Recipient
+import org.thoughtcrime.securesms.recipients.RecipientId
+
+class AddToGroupsViewModelV2(
+ private val recipientId: RecipientId,
+ private val existingGroupMemberships: Set,
+ selectionLimits: SelectionLimits?
+) : ViewModel() {
+
+ companion object {
+ private val TAG = Log.tag(AddToGroupsViewModelV2::class)
+ }
+
+ private val internalUiState = MutableStateFlow(
+ AddToGroupsUiState(
+ existingGroupMemberships = existingGroupMemberships,
+ selectionLimits = selectionLimits
+ )
+ )
+ val uiState: StateFlow = internalUiState.asStateFlow()
+
+ private val repository: GroupManagementRepository = GroupManagementRepository()
+
+ fun onSearchQueryChanged(query: String) {
+ internalUiState.update { it.copy(searchQuery = query) }
+ }
+
+ fun selectGroups(newSelections: List) {
+ val selectedGroupIds = newSelections.map { it.getOrCreateRecipientId() }.toSet()
+
+ if (internalUiState.value.isMultiSelectEnabled) {
+ updateSelection(selectedGroupIds)
+ } else {
+ confirmAddToGroup(groupRecipientId = selectedGroupIds.single())
+ }
+ }
+
+ private fun confirmAddToGroup(groupRecipientId: RecipientId) {
+ viewModelScope.launch {
+ withContext(Dispatchers.IO) {
+ if (!existingGroupMemberships.contains(groupRecipientId)) {
+ internalUiState.update {
+ it.copy(
+ userMessage = UserMessage.ConfirmAddToGroup(
+ recipientToAdd = Recipient.resolved(recipientId),
+ targetGroup = Recipient.resolved(id = groupRecipientId)
+ )
+ )
+ }
+ }
+ }
+ }
+ }
+
+ private fun updateSelection(selectedGroupIds: Set) {
+ internalUiState.update {
+ it.copy(
+ searchQuery = "",
+ newSelections = selectedGroupIds
+ )
+ }
+ }
+
+ fun addToGroups(groupRecipients: List) {
+ if (groupRecipients.size > 1) {
+ throw UnsupportedOperationException("Multi-select is not yet supported.")
+ }
+
+ viewModelScope.launch {
+ withContext(Dispatchers.IO) {
+ val recipient = Recipient.resolved(recipientId)
+ val groupRecipient = groupRecipients.single()
+
+ if (groupRecipient.groupId.get().isV1 && !recipient.hasE164) {
+ internalUiState.update {
+ it.copy(userMessage = UserMessage.CantAddRecipientToLegacyGroup)
+ }
+ return@withContext
+ }
+
+ repository.addMembers(groupRecipient, listOf(recipient.id)) { result ->
+ when (result) {
+ is GroupAddMembersResult.Success -> {
+ internalUiState.update {
+ it.copy(userMessage = UserMessage.AddedRecipientToGroup(recipient, groupRecipient))
+ }
+ }
+
+ is GroupAddMembersResult.Failure -> {
+ internalUiState.update {
+ it.copy(userMessage = UserMessage.GroupUpdateError(result.reason))
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ fun addToSelectedGroups() {
+ val selectedGroups = internalUiState.value.newSelections
+ throw UnsupportedOperationException("Not yet built to handle multi-select.")
+ }
+
+ fun clearUserMessage() {
+ internalUiState.update { it.copy(userMessage = null) }
+ }
+}
+
+data class AddToGroupsUiState(
+ val forceSplitPane: Boolean = SignalStore.internal.forceSplitPane,
+ val searchQuery: String = "",
+ val existingGroupMemberships: Set = emptySet(),
+ val selectionLimits: SelectionLimits? = null,
+ val newSelections: Set = emptySet(),
+ val userMessage: UserMessage? = null
+) {
+ val isMultiSelectEnabled: Boolean
+ get() = selectionLimits != null
+
+ sealed interface UserMessage {
+ data class ConfirmAddToGroup(val recipientToAdd: Recipient, val targetGroup: Recipient) : UserMessage
+ data class AddedRecipientToGroup(val recipient: Recipient, val targetGroup: Recipient) : UserMessage
+ data object CantAddRecipientToLegacyGroup : UserMessage
+ data class GroupUpdateError(val failureReason: GroupChangeFailureReason) : UserMessage
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/RecipientPicker.kt b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/RecipientPicker.kt
index b679d78ef4..5a7c6ab0db 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/RecipientPicker.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/RecipientPicker.kt
@@ -68,6 +68,7 @@ fun RecipientPicker(
searchQuery: String,
displayModes: Set = setOf(RecipientPicker.DisplayMode.ALL),
selectionLimits: SelectionLimits? = ContactSelectionArguments.Defaults.SELECTION_LIMITS,
+ includeRecents: Boolean = ContactSelectionArguments.Defaults.INCLUDE_RECENTS,
isRefreshing: Boolean,
focusAndShowKeyboard: Boolean = LocalConfiguration.current.screenHeightDp.dp > 600.dp,
preselectedRecipients: Set = emptySet(),
@@ -109,6 +110,7 @@ fun RecipientPicker(
displayModes = displayModes,
selectionLimits = selectionLimits,
searchQuery = searchQuery,
+ includeRecents = includeRecents,
isRefreshing = isRefreshing,
preselectedRecipients = preselectedRecipients,
pendingRecipientSelections = pendingRecipientSelections,
@@ -128,6 +130,7 @@ fun RecipientPicker(
private fun RecipientSearchResultsList(
displayModes: Set,
searchQuery: String,
+ includeRecents: Boolean,
isRefreshing: Boolean,
preselectedRecipients: Set,
pendingRecipientSelections: Set,
@@ -145,6 +148,7 @@ private fun RecipientSearchResultsList(
enableFindByUsername = callbacks.findByUsername != null,
enableFindByPhoneNumber = callbacks.findByPhoneNumber != null,
showCallButtons = callbacks.newCall != null,
+ includeRecents = includeRecents,
currentSelection = preselectedRecipients,
selectionLimits = selectionLimits,
recyclerPadBottom = with(LocalDensity.current) { bottomPadding?.toPx()?.toInt() ?: ContactSelectionArguments.Defaults.RECYCLER_PADDING_BOTTOM },