diff --git a/.idea/vcs.xml b/.idea/vcs.xml
index 4d760f2f2..e0146fae7 100644
--- a/.idea/vcs.xml
+++ b/.idea/vcs.xml
@@ -12,7 +12,6 @@
-
\ No newline at end of file
diff --git a/app/src/sharedTest/kotlin/com/android/messaging/ui/conversation/messages/ui/message/rendering/BaseConversationMessageRenderingTest.kt b/app/src/sharedTest/kotlin/com/android/messaging/ui/conversation/messages/ui/message/rendering/BaseConversationMessageRenderingTest.kt
index d238dc2cf..b9742c3ec 100644
--- a/app/src/sharedTest/kotlin/com/android/messaging/ui/conversation/messages/ui/message/rendering/BaseConversationMessageRenderingTest.kt
+++ b/app/src/sharedTest/kotlin/com/android/messaging/ui/conversation/messages/ui/message/rendering/BaseConversationMessageRenderingTest.kt
@@ -38,6 +38,7 @@ internal abstract class BaseConversationMessageRenderingTest {
protected val onExternalUriClick = mockk<(String) -> Unit>(relaxed = true)
protected val onMessageClick = mockk<() -> Unit>(relaxed = true)
protected val onMessageLongClick = mockk<() -> Unit>(relaxed = true)
+ protected val onPhoneNumberCopy = mockk<(String) -> Unit>(relaxed = true)
protected val onResendClick = mockk<() -> Unit>(relaxed = true)
protected val onSimSelectorClick = mockk<() -> Unit>(relaxed = true)
@@ -67,6 +68,7 @@ internal abstract class BaseConversationMessageRenderingTest {
onMessageAvatarClick = onAvatarClick,
onMessageDownloadClick = onDownloadClick,
onMessageLongClick = onMessageLongClick,
+ onPhoneNumberCopy = onPhoneNumberCopy,
onMessageResendClick = onResendClick,
onSimSelectorClick = onSimSelectorClick,
)
diff --git a/app/src/test/kotlin/com/android/messaging/ui/common/components/participant/ParticipantQuickActionsPopupTest.kt b/app/src/test/kotlin/com/android/messaging/ui/common/components/participant/ParticipantQuickActionsPopupTest.kt
new file mode 100644
index 000000000..973546401
--- /dev/null
+++ b/app/src/test/kotlin/com/android/messaging/ui/common/components/participant/ParticipantQuickActionsPopupTest.kt
@@ -0,0 +1,134 @@
+package com.android.messaging.ui.common.components.participant
+
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Person
+import androidx.compose.ui.semantics.SemanticsActions
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.test.onNodeWithContentDescription
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.onNodeWithText
+import androidx.compose.ui.test.performClick
+import androidx.compose.ui.test.performSemanticsAction
+import com.android.common.test.helpers.targetContext
+import com.android.messaging.R
+import com.android.messaging.ui.core.AppTheme
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.persistentListOf
+import org.junit.Assert.assertEquals
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+
+@RunWith(RobolectricTestRunner::class)
+internal class ParticipantQuickActionsPopupTest {
+
+ @get:Rule
+ val composeTestRule = createComposeRule()
+
+ @Test
+ fun copyAreaClick_withOneTarget_copiesPhoneNumber() {
+ var copiedPhoneNumber: String? = null
+ setPopup(
+ targets = persistentListOf(target("Ada", "+1 555 0001")),
+ onPhoneNumberCopy = { copiedPhoneNumber = it },
+ )
+
+ composeTestRule
+ .onNodeWithTag(PARTICIPANT_QUICK_ACTIONS_COPY_AREA_TEST_TAG)
+ .performClick()
+
+ assertEquals("+1 555 0001", copiedPhoneNumber)
+ }
+
+ @Test
+ fun copyAreaLongClick_withOneTarget_copiesPhoneNumber() {
+ var copiedPhoneNumber: String? = null
+ setPopup(
+ targets = persistentListOf(target("Ada", "+1 555 0001")),
+ onPhoneNumberCopy = { copiedPhoneNumber = it },
+ )
+
+ composeTestRule
+ .onNodeWithTag(PARTICIPANT_QUICK_ACTIONS_COPY_AREA_TEST_TAG)
+ .performSemanticsAction(SemanticsActions.OnLongClick)
+
+ assertEquals("+1 555 0001", copiedPhoneNumber)
+ }
+
+ @Test
+ fun copyAreaClick_withMultipleTargets_showsChooserAndCopiesSelection() {
+ var copiedPhoneNumber: String? = null
+ setPopup(
+ targets = persistentListOf(
+ target("Ada", "+1 555 0001"),
+ target("Grace", "+1 555 0002"),
+ ),
+ onPhoneNumberCopy = { copiedPhoneNumber = it },
+ )
+
+ composeTestRule
+ .onNodeWithTag(PARTICIPANT_QUICK_ACTIONS_COPY_AREA_TEST_TAG)
+ .performClick()
+ composeTestRule
+ .onNodeWithText("Grace")
+ .assertIsDisplayed()
+ .performClick()
+
+ assertEquals("+1 555 0002", copiedPhoneNumber)
+ }
+
+ @Test
+ fun actionButtonClick_doesNotCopyPhoneNumber() {
+ var messageClicks = 0
+ var copyClicks = 0
+ setPopup(
+ targets = persistentListOf(target("Ada", "+1 555 0001")),
+ onMessageClick = { messageClicks++ },
+ onPhoneNumberCopy = { copyClicks++ },
+ )
+
+ composeTestRule
+ .onNodeWithContentDescription(
+ targetContext.getString(R.string.action_send_message),
+ )
+ .performClick()
+
+ assertEquals(1, messageClicks)
+ assertEquals(0, copyClicks)
+ }
+
+ private fun setPopup(
+ targets: ImmutableList,
+ onMessageClick: () -> Unit = {},
+ onPhoneNumberCopy: (String) -> Unit,
+ ) {
+ composeTestRule.setContent {
+ AppTheme {
+ ParticipantQuickActionsPopup(
+ visible = true,
+ avatarUri = null,
+ displayName = "Conversation",
+ subtitle = null,
+ fallbackIcon = Icons.Default.Person,
+ fallbackLabel = "C",
+ onDismiss = {},
+ onMessageClick = onMessageClick,
+ onCallClick = {},
+ onContactClick = {},
+ onInfoClick = {},
+ colorSeedCode = null,
+ phoneNumberCopyTargets = targets,
+ onPhoneNumberCopy = onPhoneNumberCopy,
+ )
+ }
+ }
+ composeTestRule.waitForIdle()
+ }
+
+ private fun target(displayName: String, phoneNumber: String) = PhoneNumberCopyTarget(
+ displayName = displayName,
+ phoneNumber = phoneNumber,
+ )
+}
diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/ui/message/rendering/ConversationMessageBubbleInteractionTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/ui/message/rendering/ConversationMessageBubbleInteractionTest.kt
index 0ecc46c6f..b06d0dd8b 100644
--- a/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/ui/message/rendering/ConversationMessageBubbleInteractionTest.kt
+++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/ui/message/rendering/ConversationMessageBubbleInteractionTest.kt
@@ -1,9 +1,11 @@
package com.android.messaging.ui.conversation.messages.ui.message.rendering
+import androidx.compose.ui.semantics.SemanticsActions
import androidx.compose.ui.test.assertIsSelected
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
+import androidx.compose.ui.test.performSemanticsAction
import com.android.messaging.ui.conversation.conversationMessageSelectionRowTestTag
import com.android.messaging.ui.conversation.messages.model.message.ConversationMessageUiModel
import com.android.messaging.ui.conversation.messages.model.message.MmsDownloadUiModel
@@ -222,6 +224,54 @@ internal class ConversationMessageBubbleInteractionTest :
}
}
+ @Test
+ fun incomingSenderLongClick_copiesPhoneNumberWithoutSelectingMessage() {
+ setConversationMessageContent(
+ message = message(
+ status = ConversationMessageUiModel.Status.Incoming.Complete,
+ isIncoming = true,
+ senderDisplayName = "Nora",
+ senderNormalizedDestination = "+15550123",
+ ),
+ )
+
+ composeTestRule
+ .onNodeWithText(text = "Nora")
+ .performSemanticsAction(SemanticsActions.OnLongClick)
+
+ composeTestRule.runOnIdle {
+ verify(exactly = 1) {
+ onPhoneNumberCopy.invoke("+15550123")
+ }
+ verify(exactly = 0) {
+ onMessageLongClick.invoke()
+ }
+ }
+ }
+
+ @Test
+ fun incomingMessageBodyLongClick_keepsMessageSelectionBehavior() {
+ setConversationMessageContent(
+ message = message(
+ status = ConversationMessageUiModel.Status.Incoming.Complete,
+ isIncoming = true,
+ senderDisplayName = "Nora",
+ senderNormalizedDestination = "+15550123",
+ ),
+ )
+
+ longClickBubble()
+
+ composeTestRule.runOnIdle {
+ verify(exactly = 1) {
+ onMessageLongClick.invoke()
+ }
+ verify(exactly = 0) {
+ onPhoneNumberCopy.invoke(any())
+ }
+ }
+ }
+
@Test
fun incomingAvatarClick_forwardsAvatarClick() {
setConversationMessageContent(
diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/metadata/delegate/ConversationMetadataDelegateImplTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/metadata/delegate/ConversationMetadataDelegateImplTest.kt
index ee63ef653..5ed0f08ec 100644
--- a/app/src/test/kotlin/com/android/messaging/ui/conversation/metadata/delegate/ConversationMetadataDelegateImplTest.kt
+++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/metadata/delegate/ConversationMetadataDelegateImplTest.kt
@@ -4,6 +4,8 @@ import app.cash.turbine.test
import com.android.messaging.data.blockedparticipants.repository.BlockedParticipantsRepository
import com.android.messaging.data.conversation.model.metadata.ConversationComposerAvailability
import com.android.messaging.data.conversation.model.metadata.ConversationMetadata
+import com.android.messaging.data.conversation.model.recipient.ConversationRecipient
+import com.android.messaging.data.conversation.repository.ConversationParticipantsRepository
import com.android.messaging.data.conversation.repository.ConversationsRepository
import com.android.messaging.domain.conversation.usecase.action.ConversationActionRequirementsResult
import com.android.messaging.testutil.MainDispatcherRule
@@ -15,6 +17,8 @@ import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.MutableStateFlow
@@ -130,6 +134,43 @@ class ConversationMetadataDelegateImplTest {
}
}
+ @Test
+ fun participants_areExposedAsPhoneNumberCopyTargets_andEmailIsExcluded() {
+ runTest(context = mainDispatcherRule.testDispatcher) {
+ val harness = createHarness(conversationId = "conversation-42")
+
+ try {
+ harness.participantsFlow.value = persistentListOf(
+ ConversationRecipient(
+ id = "participant-1",
+ displayName = "Alice",
+ destination = "+15550001",
+ ),
+ ConversationRecipient(
+ id = "participant-2",
+ displayName = "Email only",
+ destination = "person@example.com",
+ ),
+ )
+ harness.setPresentState(otherParticipantPhoneNumber = null)
+ advanceUntilIdle()
+
+ val state = harness.delegate.state.value as ConversationMetadataUiState.Present
+ assertEquals(
+ persistentListOf(
+ ConversationMetadataUiState.PhoneNumberCopyTarget(
+ displayName = "Alice",
+ phoneNumber = "+15550001",
+ ),
+ ),
+ state.phoneNumberCopyTargets,
+ )
+ } finally {
+ harness.cancel()
+ }
+ }
+ }
+
@Test
fun onDeleteConversationClick_togglesConfirmationVisibility() {
runTest(context = mainDispatcherRule.testDispatcher) {
@@ -241,6 +282,12 @@ class ConversationMetadataDelegateImplTest {
val dispatcher = mainDispatcherRule.testDispatcher
val scope = TestScope(dispatcher)
val conversationsRepository = mockk(relaxed = true)
+ val participantsFlow = MutableStateFlow>(
+ persistentListOf(),
+ )
+ val conversationParticipantsRepository = mockk() {
+ every { getParticipants(any()) } returns participantsFlow
+ }
val mapper = mockk()
val conversationIdFlow = MutableStateFlow(conversationId)
val metadataFlow = MutableStateFlow(value = null)
@@ -275,6 +322,7 @@ class ConversationMetadataDelegateImplTest {
ConversationActionRequirementsResult.Ready
},
conversationsRepository = conversationsRepository,
+ conversationParticipantsRepository = conversationParticipantsRepository,
conversationMetadataUiStateMapper = mapper,
blockedParticipantsRepository = mockk(relaxed = true),
defaultDispatcher = dispatcher,
@@ -288,6 +336,7 @@ class ConversationMetadataDelegateImplTest {
delegate = delegate,
conversationsRepository = conversationsRepository,
metadataFlow = metadataFlow,
+ participantsFlow = participantsFlow,
scope = scope,
)
}
@@ -304,6 +353,7 @@ class ConversationMetadataDelegateImplTest {
val delegate: ConversationMetadataDelegateImpl,
val conversationsRepository: ConversationsRepository,
val metadataFlow: MutableStateFlow,
+ val participantsFlow: MutableStateFlow>,
val scope: TestScope,
) {
fun cancel() {
diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/metadata/ui/ConversationTopAppBarTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/metadata/ui/ConversationTopAppBarTest.kt
index 5286c997d..8ca4fe3e2 100644
--- a/app/src/test/kotlin/com/android/messaging/ui/conversation/metadata/ui/ConversationTopAppBarTest.kt
+++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/metadata/ui/ConversationTopAppBarTest.kt
@@ -1,11 +1,13 @@
package com.android.messaging.ui.conversation.metadata.ui
+import androidx.compose.ui.semantics.SemanticsActions
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.v2.createComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
+import androidx.compose.ui.test.performSemanticsAction
import com.android.common.test.helpers.targetContext
import com.android.messaging.R
import com.android.messaging.data.conversation.model.metadata.ConversationComposerAvailability
@@ -20,6 +22,7 @@ import com.android.messaging.ui.conversation.CONVERSATION_TOP_APP_BAR_TITLE_TEST
import com.android.messaging.ui.conversation.CONVERSATION_UNARCHIVE_BUTTON_TEST_TAG
import com.android.messaging.ui.conversation.metadata.model.ConversationMetadataUiState
import com.android.messaging.ui.core.AppTheme
+import kotlinx.collections.immutable.persistentListOf
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
@@ -65,6 +68,65 @@ class ConversationTopAppBarTest {
}
}
+ @Test
+ fun titleLongClick_withOnePhoneNumber_copiesItDirectly() {
+ var copiedPhoneNumber: String? = null
+
+ setContent(
+ metadata = presentMetadata.copy(
+ phoneNumberCopyTargets = persistentListOf(
+ ConversationMetadataUiState.PhoneNumberCopyTarget(
+ displayName = "Carol",
+ phoneNumber = "+37254400024",
+ ),
+ ),
+ ),
+ onPhoneNumberCopy = { copiedPhoneNumber = it },
+ )
+
+ composeTestRule
+ .onNodeWithTag(testTag = CONVERSATION_TOP_APP_BAR_TITLE_TEST_TAG)
+ .performSemanticsAction(SemanticsActions.OnLongClick)
+
+ composeTestRule.runOnIdle {
+ assertEquals("+37254400024", copiedPhoneNumber)
+ }
+ }
+
+ @Test
+ fun titleLongClick_withMultiplePhoneNumbers_showsChooserAndCopiesSelection() {
+ var copiedPhoneNumber: String? = null
+
+ setContent(
+ metadata = presentMetadata.copy(
+ participantCount = 2,
+ phoneNumberCopyTargets = persistentListOf(
+ ConversationMetadataUiState.PhoneNumberCopyTarget(
+ displayName = "Alice",
+ phoneNumber = "+15550001",
+ ),
+ ConversationMetadataUiState.PhoneNumberCopyTarget(
+ displayName = "Bob",
+ phoneNumber = "+15550002",
+ ),
+ ),
+ ),
+ onPhoneNumberCopy = { copiedPhoneNumber = it },
+ )
+
+ composeTestRule
+ .onNodeWithTag(testTag = CONVERSATION_TOP_APP_BAR_TITLE_TEST_TAG)
+ .performSemanticsAction(SemanticsActions.OnLongClick)
+
+ composeTestRule.onNodeWithText("Alice").assertIsDisplayed()
+ composeTestRule.onNodeWithText("Bob").assertIsDisplayed().performClick()
+
+ composeTestRule.runOnIdle {
+ assertEquals("+15550002", copiedPhoneNumber)
+ }
+ composeTestRule.onNodeWithText("Alice").assertDoesNotExist()
+ }
+
@Test
fun title_whenTitleIsBlank_fallsBackToAppName() {
val appName = targetContext.getString(R.string.app_name)
@@ -330,6 +392,7 @@ class ConversationTopAppBarTest {
onDeleteConversationClick: () -> Unit = {},
onShowSubjectFieldClick: () -> Unit = {},
onTitleClick: () -> Unit = {},
+ onPhoneNumberCopy: (String) -> Unit = {},
onNavigateBack: () -> Unit = {},
) {
composeTestRule.setContent {
@@ -351,6 +414,7 @@ class ConversationTopAppBarTest {
onDeleteConversationClick = onDeleteConversationClick,
onShowSubjectFieldClick = onShowSubjectFieldClick,
onTitleClick = onTitleClick,
+ onPhoneNumberCopy = onPhoneNumberCopy,
onNavigateBack = onNavigateBack,
)
}
diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversationlist/chats/ConversationListViewModelTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversationlist/chats/ConversationListViewModelTest.kt
index 337b05497..b1df4c28e 100644
--- a/app/src/test/kotlin/com/android/messaging/ui/conversationlist/chats/ConversationListViewModelTest.kt
+++ b/app/src/test/kotlin/com/android/messaging/ui/conversationlist/chats/ConversationListViewModelTest.kt
@@ -1,6 +1,8 @@
package com.android.messaging.ui.conversationlist.chats
import app.cash.turbine.test
+import com.android.messaging.data.conversation.model.recipient.ConversationRecipient
+import com.android.messaging.data.conversation.repository.ConversationParticipantsRepository
import com.android.messaging.data.conversationlist.model.ConversationListSnapshot
import com.android.messaging.data.conversationlist.repository.ConversationListRepository
import com.android.messaging.data.debug.DebugFeaturesProvider
@@ -8,6 +10,7 @@ import com.android.messaging.testutil.MainDispatcherRule
import com.android.messaging.ui.conversationlist.chats.mapper.ConversationListUiStateMapper
import com.android.messaging.ui.conversationlist.chats.model.ConversationListAction as Action
import com.android.messaging.ui.conversationlist.chats.model.ConversationListEffect as Effect
+import com.android.messaging.ui.conversationlist.chats.model.ConversationListUiState as State
import com.android.messaging.ui.conversationlist.conversationItem
import com.android.messaging.ui.conversationlist.delegate.ConversationListActionsDelegate
import com.android.messaging.ui.conversationlist.delegate.ConversationListOptimisticSnapshotDelegate
@@ -24,6 +27,7 @@ import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
@@ -42,6 +46,7 @@ class ConversationListViewModelTest {
private val actionsDelegate = mockk()
private val optimisticSnapshotDelegate = mockk()
private val debugFeaturesProvider = mockk()
+ private val conversationParticipantsRepository = mockk()
private val snapshotFlow = MutableStateFlow(null)
private val selectedIdsFlow = MutableStateFlow>(persistentListOf())
@@ -163,6 +168,44 @@ class ConversationListViewModelTest {
}
}
+ @Test
+ fun avatarQuickActionsOpened_loadsCopyablePhoneNumbers() = runTest(
+ context = mainDispatcherRule.testDispatcher,
+ ) {
+ snapshotFlow.value = snapshotOf(conversationItem("a"))
+ every {
+ uiStateMapper.map(any(), any(), any(), any())
+ } returns State()
+ every {
+ conversationParticipantsRepository.getParticipants("a")
+ } returns flowOf(
+ persistentListOf(
+ ConversationRecipient(
+ id = "1",
+ displayName = "Ada",
+ destination = "+1 555 0001",
+ ),
+ ConversationRecipient(
+ id = "2",
+ displayName = "Email only",
+ destination = "ada@example.com",
+ ),
+ ),
+ )
+
+ val viewModel = createViewModel()
+ viewModel.uiState.test {
+ awaitItem()
+ viewModel.onAction(Action.AvatarQuickActionsOpened("a"))
+
+ val targets = awaitItem().phoneNumberCopyTargets.getValue("a")
+ assertEquals(1, targets.size)
+ assertEquals("Ada", targets.single().displayName)
+ assertEquals("+1 555 0001", targets.single().phoneNumber)
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+
@Test
fun archiveSnackbarDismissed_discardsArchivedItems() {
val viewModel = createViewModel()
@@ -244,6 +287,7 @@ class ConversationListViewModelTest {
actionsDelegate = actionsDelegate,
optimisticSnapshotDelegate = optimisticSnapshotDelegate,
debugFeaturesProvider = debugFeaturesProvider,
+ conversationParticipantsRepository = conversationParticipantsRepository,
)
}
}
diff --git a/src/com/android/messaging/ui/common/components/participant/ParticipantQuickActionsPopup.kt b/src/com/android/messaging/ui/common/components/participant/ParticipantQuickActionsPopup.kt
index 66df5c06c..1eb70080b 100644
--- a/src/com/android/messaging/ui/common/components/participant/ParticipantQuickActionsPopup.kt
+++ b/src/com/android/messaging/ui/common/components/participant/ParticipantQuickActionsPopup.kt
@@ -10,6 +10,7 @@ import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.foundation.background
+import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -28,13 +29,17 @@ import androidx.compose.material.icons.filled.Call
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.PersonAdd
+import androidx.compose.material3.DropdownMenu
+import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
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.Brush
@@ -42,8 +47,12 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.platform.LocalHapticFeedback
+import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.semantics.Role
import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Popup
@@ -52,6 +61,8 @@ import com.android.messaging.R
import com.android.messaging.ui.common.components.AnchorRelativePositionProvider
import com.android.messaging.ui.common.components.MarqueeText
import com.android.messaging.ui.core.MessagingPreviewColumn
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.persistentListOf
private val PopupWidth = 192.dp
private val ActionRowHeight = 40.dp
@@ -65,6 +76,9 @@ private val AvatarFallbackSize = 60.dp
private val IconInsidePadding = 2.dp
private val ShadowPadding = 16.dp
+internal const val PARTICIPANT_QUICK_ACTIONS_COPY_AREA_TEST_TAG =
+ "participant-quick-actions-copy-area"
+
@Composable
internal fun ParticipantQuickActionsPopup(
visible: Boolean,
@@ -80,6 +94,8 @@ internal fun ParticipantQuickActionsPopup(
onInfoClick: (() -> Unit)?,
colorSeedCode: String?,
isContactSaved: Boolean = true,
+ phoneNumberCopyTargets: ImmutableList = persistentListOf(),
+ onPhoneNumberCopy: ((String) -> Unit)? = null,
) {
val transitionState = remember { MutableTransitionState(false) }
transitionState.targetState = visible
@@ -136,6 +152,8 @@ internal fun ParticipantQuickActionsPopup(
onContactClick = onContactClick,
onInfoClick = onInfoClick,
isContactSaved = isContactSaved,
+ phoneNumberCopyTargets = phoneNumberCopyTargets,
+ onPhoneNumberCopy = onPhoneNumberCopy,
)
}
}
@@ -154,6 +172,8 @@ private fun QuickActionsCard(
onContactClick: (() -> Unit)?,
onInfoClick: (() -> Unit)?,
isContactSaved: Boolean,
+ phoneNumberCopyTargets: ImmutableList,
+ onPhoneNumberCopy: ((String) -> Unit)?,
) {
val cardShape = MaterialTheme.shapes.medium
val cardColor = MaterialTheme.colorScheme.surfaceContainerHigh
@@ -168,33 +188,38 @@ private fun QuickActionsCard(
shadowElevation = 4.dp,
) {
Column(modifier = Modifier.fillMaxWidth()) {
- AvatarHeader(
- avatarUri = avatarUri,
- fallbackIcon = fallbackIcon,
- fallbackLabel = fallbackLabel,
- colorSeedCode = colorSeedCode,
- fadeColor = cardColor,
- )
-
- Spacer(modifier = Modifier.height(ContentTopPadding))
+ PhoneNumberCopyArea(
+ targets = phoneNumberCopyTargets,
+ onPhoneNumberCopy = onPhoneNumberCopy,
+ ) {
+ AvatarHeader(
+ avatarUri = avatarUri,
+ fallbackIcon = fallbackIcon,
+ fallbackLabel = fallbackLabel,
+ colorSeedCode = colorSeedCode,
+ fadeColor = cardColor,
+ )
- MarqueeText(
- text = displayName,
- style = MaterialTheme.typography.titleSmall,
- color = MaterialTheme.colorScheme.onSurface,
- fadeEdgeWidth = ActionRowPadding,
- )
+ Spacer(modifier = Modifier.height(ContentTopPadding))
- if (!subtitle.isNullOrBlank()) {
MarqueeText(
- text = subtitle,
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
+ text = displayName,
+ style = MaterialTheme.typography.titleSmall,
+ color = MaterialTheme.colorScheme.onSurface,
fadeEdgeWidth = ActionRowPadding,
)
- }
- Spacer(modifier = Modifier.height(AvatarSubtitleSpacing))
+ if (!subtitle.isNullOrBlank()) {
+ MarqueeText(
+ text = subtitle,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ fadeEdgeWidth = ActionRowPadding,
+ )
+ }
+
+ Spacer(modifier = Modifier.height(AvatarSubtitleSpacing))
+ }
QuickActionsRow(
onMessageClick = onMessageClick,
@@ -209,6 +234,78 @@ private fun QuickActionsCard(
}
}
+@Composable
+private fun PhoneNumberCopyArea(
+ targets: ImmutableList,
+ onPhoneNumberCopy: ((String) -> Unit)?,
+ content: @Composable () -> Unit,
+) {
+ var showTargetMenu by remember { mutableStateOf(false) }
+ val hapticFeedback = LocalHapticFeedback.current
+ val copyLabel = stringResource(R.string.copy_to_clipboard)
+ val canCopy = targets.isNotEmpty() && onPhoneNumberCopy != null
+ val performAction: () -> Unit = {
+ when (targets.size) {
+ 0 -> Unit
+ 1 -> onPhoneNumberCopy?.invoke(targets.single().phoneNumber)
+ else -> showTargetMenu = true
+ }
+ }
+ val interactionModifier = when {
+ !canCopy -> Modifier
+ else -> Modifier.combinedClickable(
+ role = Role.Button,
+ onClickLabel = copyLabel,
+ onClick = performAction,
+ onLongClickLabel = copyLabel,
+ onLongClick = {
+ hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
+ performAction()
+ },
+ )
+ }
+
+ Box(modifier = Modifier.fillMaxWidth()) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .testTag(PARTICIPANT_QUICK_ACTIONS_COPY_AREA_TEST_TAG)
+ .then(interactionModifier),
+ ) {
+ content()
+ }
+
+ DropdownMenu(
+ expanded = showTargetMenu,
+ onDismissRequest = { showTargetMenu = false },
+ ) {
+ targets.forEach { target ->
+ DropdownMenuItem(
+ text = {
+ Column {
+ Text(
+ text = target.displayName,
+ style = MaterialTheme.typography.bodyMedium,
+ )
+ if (target.displayName != target.phoneNumber) {
+ Text(
+ text = target.phoneNumber,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+ },
+ onClick = {
+ showTargetMenu = false
+ onPhoneNumberCopy?.invoke(target.phoneNumber)
+ },
+ )
+ }
+ }
+ }
+}
+
@Composable
private fun AvatarHeader(
avatarUri: String?,
@@ -352,6 +449,13 @@ private fun ParticipantQuickActionsPopupPreview() {
onContactClick = {},
onInfoClick = {},
isContactSaved = false,
+ phoneNumberCopyTargets = persistentListOf(
+ PhoneNumberCopyTarget(
+ displayName = "Best friend",
+ phoneNumber = "+1 555 000 0000000",
+ ),
+ ),
+ onPhoneNumberCopy = {},
)
}
}
diff --git a/src/com/android/messaging/ui/common/components/participant/PhoneNumberCopyTarget.kt b/src/com/android/messaging/ui/common/components/participant/PhoneNumberCopyTarget.kt
new file mode 100644
index 000000000..1d10df867
--- /dev/null
+++ b/src/com/android/messaging/ui/common/components/participant/PhoneNumberCopyTarget.kt
@@ -0,0 +1,9 @@
+package com.android.messaging.ui.common.components.participant
+
+import androidx.compose.runtime.Immutable
+
+@Immutable
+internal data class PhoneNumberCopyTarget(
+ val displayName: String,
+ val phoneNumber: String,
+)
diff --git a/src/com/android/messaging/ui/conversation/messages/ui/ConversationMessages.kt b/src/com/android/messaging/ui/conversation/messages/ui/ConversationMessages.kt
index f8c12ecca..e8f4642b5 100644
--- a/src/com/android/messaging/ui/conversation/messages/ui/ConversationMessages.kt
+++ b/src/com/android/messaging/ui/conversation/messages/ui/ConversationMessages.kt
@@ -86,6 +86,7 @@ internal fun ConversationMessages(
onMessageLongClick: (String) -> Unit,
onMessageResendClick: (String) -> Unit,
onSimSelectorClick: () -> Unit = {},
+ onPhoneNumberCopy: (String) -> Unit = {},
) {
val configuration = LocalConfiguration.current
val displayMessages = remember(messages) {
@@ -134,6 +135,7 @@ internal fun ConversationMessages(
onMessageDownloadClick = onMessageDownloadClick,
onMessageLongClick = onMessageLongClick,
onMessageResendClick = onMessageResendClick,
+ onPhoneNumberCopy = onPhoneNumberCopy,
onSimSelectorClick = onSimSelectorClick,
)
}
@@ -154,6 +156,7 @@ private fun LazyListScope.conversationMessageItems(
onMessageDownloadClick: (String) -> Unit,
onMessageLongClick: (String) -> Unit,
onMessageResendClick: (String) -> Unit,
+ onPhoneNumberCopy: (String) -> Unit,
onSimSelectorClick: () -> Unit,
) {
itemsIndexed(
@@ -185,6 +188,7 @@ private fun LazyListScope.conversationMessageItems(
onMessageDownloadClick = onMessageDownloadClick,
onMessageLongClick = onMessageLongClick,
onMessageResendClick = onMessageResendClick,
+ onPhoneNumberCopy = onPhoneNumberCopy,
onSimSelectorClick = onSimSelectorClick,
)
}
@@ -306,6 +310,7 @@ private fun ConversationMessagesItem(
onMessageDownloadClick: (String) -> Unit,
onMessageLongClick: (String) -> Unit,
onMessageResendClick: (String) -> Unit,
+ onPhoneNumberCopy: (String) -> Unit,
onSimSelectorClick: () -> Unit,
) {
val presentation = rememberConversationMessagesItemPresentation(
@@ -353,6 +358,7 @@ private fun ConversationMessagesItem(
onMessageResendClick = {
onMessageResendClick(message.messageId)
},
+ onPhoneNumberCopy = onPhoneNumberCopy,
onSimSelectorClick = onSimSelectorClick,
)
}
diff --git a/src/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessage.kt b/src/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessage.kt
index 0819e9ab0..9325f0bb7 100644
--- a/src/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessage.kt
+++ b/src/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessage.kt
@@ -59,6 +59,7 @@ internal fun ConversationMessage(
onMessageLongClick: () -> Unit = {},
onMessageResendClick: () -> Unit = {},
onSimSelectorClick: () -> Unit = {},
+ onPhoneNumberCopy: (String) -> Unit = {},
) {
BoxWithConstraints(
modifier = modifier
@@ -103,6 +104,7 @@ internal fun ConversationMessage(
onMessageDownloadClick = onMessageDownloadClick,
onMessageLongClick = onMessageLongClick,
onMessageResendClick = onMessageResendClick,
+ onPhoneNumberCopy = onPhoneNumberCopy,
onSimSelectorClick = onSimSelectorClick,
)
}
@@ -289,6 +291,7 @@ private fun ConversationMessageContent(
onMessageDownloadClick: () -> Unit,
onMessageLongClick: () -> Unit,
onMessageResendClick: () -> Unit,
+ onPhoneNumberCopy: (String) -> Unit,
onSimSelectorClick: () -> Unit,
) {
Column(
@@ -308,6 +311,7 @@ private fun ConversationMessageContent(
onMessageDownloadClick = onMessageDownloadClick,
onMessageLongClick = onMessageLongClick,
onMessageResendClick = onMessageResendClick,
+ onPhoneNumberCopy = onPhoneNumberCopy,
)
ConversationMessageMetadataRow(
diff --git a/src/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessageAttachmentBubble.kt b/src/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessageAttachmentBubble.kt
index 6e33eebb3..0efd72827 100644
--- a/src/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessageAttachmentBubble.kt
+++ b/src/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessageAttachmentBubble.kt
@@ -16,6 +16,7 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
+import com.android.messaging.sms.MmsSmsUtils
import com.android.messaging.ui.conversation.messages.model.message.ConversationMessageContent
import com.android.messaging.ui.conversation.messages.model.message.ConversationMessageUiModel
import com.android.messaging.ui.conversation.messages.ui.attachment.ConversationMessageAttachments
@@ -36,6 +37,7 @@ internal fun ConversationMessageAttachmentOnlyBubble(
onAttachmentClick: OnConversationAttachmentClick,
onExternalUriClick: (String) -> Unit,
onMessageLongClick: () -> Unit,
+ onPhoneNumberCopy: (String) -> Unit = {},
) {
ConversationMessageAttachmentOnlyContainer(
modifier = modifier,
@@ -52,6 +54,7 @@ internal fun ConversationMessageAttachmentOnlyBubble(
onAttachmentClick = onAttachmentClick,
onExternalUriClick = onExternalUriClick,
onMessageLongClick = onMessageLongClick,
+ onPhoneNumberCopy = onPhoneNumberCopy,
)
}
}
@@ -66,6 +69,7 @@ internal fun ConversationMessageAttachmentSurfaceBubble(
onAttachmentClick: OnConversationAttachmentClick,
onExternalUriClick: (String) -> Unit,
onMessageLongClick: () -> Unit,
+ onPhoneNumberCopy: (String) -> Unit = {},
) {
ConversationMessageBubbleSurface(
modifier = modifier,
@@ -81,6 +85,7 @@ internal fun ConversationMessageAttachmentSurfaceBubble(
onAttachmentClick = onAttachmentClick,
onExternalUriClick = onExternalUriClick,
onMessageLongClick = onMessageLongClick,
+ onPhoneNumberCopy = onPhoneNumberCopy,
)
}
}
@@ -133,6 +138,7 @@ private fun ConversationMessageAttachmentBubbleContent(
onAttachmentClick: OnConversationAttachmentClick,
onExternalUriClick: (String) -> Unit,
onMessageLongClick: () -> Unit,
+ onPhoneNumberCopy: (String) -> Unit = {},
) {
val content = layout.content
val hasHeader = layout.showSender || !content.subjectText.isNullOrBlank()
@@ -154,6 +160,10 @@ private fun ConversationMessageAttachmentBubbleContent(
),
senderDisplayName = message.senderDisplayName,
showSender = layout.showSender,
+ phoneNumber = message.senderNormalizedDestination
+ ?.takeUnless { isSelectionMode }
+ ?.takeIf(MmsSmsUtils::isPhoneNumber),
+ onPhoneNumberCopy = onPhoneNumberCopy,
)
ConversationMessageSubject(
diff --git a/src/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessageBubble.kt b/src/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessageBubble.kt
index 384bf5292..fcdf94210 100644
--- a/src/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessageBubble.kt
+++ b/src/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessageBubble.kt
@@ -1,5 +1,6 @@
package com.android.messaging.ui.conversation.messages.ui.message
+import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
@@ -11,10 +12,15 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.hapticfeedback.HapticFeedbackType
+import androidx.compose.ui.platform.LocalHapticFeedback
+import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
+import com.android.messaging.R
+import com.android.messaging.sms.MmsSmsUtils
import com.android.messaging.ui.conversation.messages.model.message.ConversationMessageContent
import com.android.messaging.ui.conversation.messages.model.message.ConversationMessageUiModel
import com.android.messaging.ui.conversation.messages.model.message.ConversationMessageUiModel.Status
@@ -47,6 +53,7 @@ internal fun ConversationMessageBubble(
onAttachmentClick: OnConversationAttachmentClick,
onExternalUriClick: (String) -> Unit,
onMessageLongClick: () -> Unit,
+ onPhoneNumberCopy: (String) -> Unit = {},
) {
val bubbleModifier = Modifier
.widthIn(max = maxBubbleWidth)
@@ -63,6 +70,7 @@ internal fun ConversationMessageBubble(
onAttachmentClick = onAttachmentClick,
onExternalUriClick = onExternalUriClick,
onMessageLongClick = onMessageLongClick,
+ onPhoneNumberCopy = onPhoneNumberCopy,
)
}
@@ -76,6 +84,7 @@ internal fun ConversationMessageBubble(
onAttachmentClick = onAttachmentClick,
onExternalUriClick = onExternalUriClick,
onMessageLongClick = onMessageLongClick,
+ onPhoneNumberCopy = onPhoneNumberCopy,
)
}
@@ -90,6 +99,7 @@ internal fun ConversationMessageBubble(
onAttachmentClick = onAttachmentClick,
onExternalUriClick = onExternalUriClick,
onMessageLongClick = onMessageLongClick,
+ onPhoneNumberCopy = onPhoneNumberCopy,
)
}
}
@@ -106,6 +116,7 @@ private fun ConversationMessageTextSurfaceBubble(
onAttachmentClick: OnConversationAttachmentClick,
onExternalUriClick: (String) -> Unit,
onMessageLongClick: () -> Unit,
+ onPhoneNumberCopy: (String) -> Unit,
) {
ConversationMessageBubbleSurface(
modifier = modifier,
@@ -122,6 +133,7 @@ private fun ConversationMessageTextSurfaceBubble(
onAttachmentClick = onAttachmentClick,
onExternalUriClick = onExternalUriClick,
onMessageLongClick = onMessageLongClick,
+ onPhoneNumberCopy = onPhoneNumberCopy,
)
}
}
@@ -166,6 +178,7 @@ private fun ConversationMessageTextBubbleContent(
onAttachmentClick: OnConversationAttachmentClick,
onExternalUriClick: (String) -> Unit,
onMessageLongClick: () -> Unit,
+ onPhoneNumberCopy: (String) -> Unit,
) {
Column(
modifier = Modifier.padding(
@@ -181,6 +194,10 @@ private fun ConversationMessageTextBubbleContent(
),
senderDisplayName = message.senderDisplayName,
showSender = layout.showSender,
+ phoneNumber = message.senderNormalizedDestination
+ ?.takeUnless { isSelectionMode }
+ ?.takeIf(MmsSmsUtils::isPhoneNumber),
+ onPhoneNumberCopy = onPhoneNumberCopy,
)
when {
@@ -268,13 +285,29 @@ internal fun ConversationMessageSender(
color: Color,
senderDisplayName: String?,
showSender: Boolean,
+ phoneNumber: String? = null,
+ onPhoneNumberCopy: (String) -> Unit = {},
) {
if (!showSender || senderDisplayName == null) {
return
}
+ val hapticFeedback = LocalHapticFeedback.current
+ val copyLabel = stringResource(id = R.string.copy_to_clipboard)
+ val copyModifier = when (phoneNumber) {
+ null -> Modifier
+ else -> Modifier.combinedClickable(
+ onClick = {},
+ onLongClickLabel = copyLabel,
+ onLongClick = {
+ hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
+ onPhoneNumberCopy(phoneNumber)
+ },
+ )
+ }
+
Text(
- modifier = modifier,
+ modifier = modifier.then(copyModifier),
text = senderDisplayName,
style = MaterialTheme.typography.labelMedium,
color = color,
diff --git a/src/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessageRows.kt b/src/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessageRows.kt
index 9b2419a36..9f7236f61 100644
--- a/src/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessageRows.kt
+++ b/src/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessageRows.kt
@@ -56,6 +56,7 @@ internal fun ConversationMessageBubbleRow(
onMessageDownloadClick: () -> Unit,
onMessageLongClick: () -> Unit,
onMessageResendClick: () -> Unit,
+ onPhoneNumberCopy: (String) -> Unit = {},
) {
ConversationMessageBubbleRowContainer(
message = message,
@@ -98,6 +99,7 @@ internal fun ConversationMessageBubbleRow(
}
},
onMessageLongClick = onMessageLongClick,
+ onPhoneNumberCopy = onPhoneNumberCopy,
)
}
}
diff --git a/src/com/android/messaging/ui/conversation/metadata/delegate/ConversationMetadataDelegate.kt b/src/com/android/messaging/ui/conversation/metadata/delegate/ConversationMetadataDelegate.kt
index 9d5ad4b0a..a4a08be16 100644
--- a/src/com/android/messaging/ui/conversation/metadata/delegate/ConversationMetadataDelegate.kt
+++ b/src/com/android/messaging/ui/conversation/metadata/delegate/ConversationMetadataDelegate.kt
@@ -3,15 +3,21 @@ package com.android.messaging.ui.conversation.metadata.delegate
import com.android.messaging.R
import com.android.messaging.data.blockedparticipants.repository.BlockedParticipantsRepository
import com.android.messaging.data.conversation.model.metadata.ConversationMetadata
+import com.android.messaging.data.conversation.model.recipient.ConversationRecipient
+import com.android.messaging.data.conversation.repository.ConversationParticipantsRepository
import com.android.messaging.data.conversation.repository.ConversationsRepository
import com.android.messaging.di.core.DefaultDispatcher
import com.android.messaging.domain.conversation.usecase.action.CheckConversationActionRequirements
import com.android.messaging.domain.conversation.usecase.action.ConversationActionRequirementsResult
+import com.android.messaging.sms.MmsSmsUtils
import com.android.messaging.ui.conversation.common.ConversationScreenDelegate
import com.android.messaging.ui.conversation.metadata.mapper.ConversationMetadataUiStateMapper
import com.android.messaging.ui.conversation.metadata.model.ConversationMetadataUiState
import com.android.messaging.ui.conversation.screen.model.ConversationScreenEffect
import javax.inject.Inject
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.persistentListOf
+import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
@@ -21,9 +27,8 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flowOn
-import kotlinx.coroutines.flow.map
-import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
internal interface ConversationMetadataDelegate :
@@ -43,6 +48,7 @@ internal interface ConversationMetadataDelegate :
internal class ConversationMetadataDelegateImpl @Inject constructor(
private val checkConversationActionRequirements: CheckConversationActionRequirements,
private val conversationsRepository: ConversationsRepository,
+ private val conversationParticipantsRepository: ConversationParticipantsRepository,
private val conversationMetadataUiStateMapper: ConversationMetadataUiStateMapper,
private val blockedParticipantsRepository: BlockedParticipantsRepository,
@param:DefaultDispatcher
@@ -87,17 +93,25 @@ internal class ConversationMetadataDelegateImpl @Inject constructor(
return@collectLatest
}
- conversationsRepository
- .getConversationMetadata(conversationId = conversationId)
- .onEach { metadata -> latestMetadata = metadata }
- .map { metadata ->
- when {
- metadata != null -> {
- conversationMetadataUiStateMapper.map(metadata = metadata)
- }
- else -> ConversationMetadataUiState.Unavailable
+ combine(
+ conversationsRepository.getConversationMetadata(
+ conversationId = conversationId,
+ ),
+ conversationParticipantsRepository.getParticipants(
+ conversationId = conversationId,
+ ),
+ ) { metadata, participants ->
+ latestMetadata = metadata
+
+ when {
+ metadata != null -> {
+ conversationMetadataUiStateMapper
+ .map(metadata = metadata)
+ .withPhoneNumberCopyTargets(participants = participants)
}
+ else -> ConversationMetadataUiState.Unavailable
}
+ }
.flowOn(defaultDispatcher)
.collect { currentMetadataState ->
_state.value = currentMetadataState
@@ -220,3 +234,44 @@ internal class ConversationMetadataDelegateImpl @Inject constructor(
?.takeIf { it.isNotBlank() }
}
}
+
+private fun ConversationMetadataUiState.withPhoneNumberCopyTargets(
+ participants: ImmutableList,
+): ConversationMetadataUiState {
+ if (this !is ConversationMetadataUiState.Present) {
+ return this
+ }
+
+ val participantTargets = participants
+ .mapNotNull { participant ->
+ val phoneNumber = participant.destination
+ .trim()
+ .takeIf { it.isNotBlank() }
+ ?.takeIf(MmsSmsUtils::isPhoneNumber)
+ ?: return@mapNotNull null
+
+ ConversationMetadataUiState.PhoneNumberCopyTarget(
+ displayName = participant.displayName
+ .takeIf { it.isNotBlank() }
+ ?: phoneNumber,
+ phoneNumber = phoneNumber,
+ )
+ }
+ .toImmutableList()
+
+ val targets = when {
+ participantTargets.isNotEmpty() -> participantTargets
+ otherParticipantPhoneNumber != null -> {
+ persistentListOf(
+ ConversationMetadataUiState.PhoneNumberCopyTarget(
+ displayName = title.takeIf { it.isNotBlank() }
+ ?: otherParticipantPhoneNumber,
+ phoneNumber = otherParticipantPhoneNumber,
+ ),
+ )
+ }
+ else -> participantTargets
+ }
+
+ return copy(phoneNumberCopyTargets = targets)
+}
diff --git a/src/com/android/messaging/ui/conversation/metadata/model/ConversationMetadataUiState.kt b/src/com/android/messaging/ui/conversation/metadata/model/ConversationMetadataUiState.kt
index 84fbb51a6..0538b9a03 100644
--- a/src/com/android/messaging/ui/conversation/metadata/model/ConversationMetadataUiState.kt
+++ b/src/com/android/messaging/ui/conversation/metadata/model/ConversationMetadataUiState.kt
@@ -3,6 +3,8 @@ package com.android.messaging.ui.conversation.metadata.model
import androidx.compose.runtime.Immutable
import com.android.messaging.data.conversation.model.metadata.ConversationComposerAvailability
import com.android.messaging.data.conversation.model.metadata.ConversationComposerDisabledReason
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.persistentListOf
@Immutable
internal sealed interface ConversationMetadataUiState {
@@ -40,8 +42,15 @@ internal sealed interface ConversationMetadataUiState {
val isArchived: Boolean,
val isBlocked: Boolean,
override val composerAvailability: ConversationComposerAvailability,
+ val phoneNumberCopyTargets: ImmutableList = persistentListOf(),
) : ConversationMetadataUiState
+ @Immutable
+ data class PhoneNumberCopyTarget(
+ val displayName: String,
+ val phoneNumber: String,
+ )
+
@Immutable
data object Unavailable : ConversationMetadataUiState {
override val composerAvailability = ConversationComposerAvailability.Unavailable(
diff --git a/src/com/android/messaging/ui/conversation/metadata/ui/ConversationTopAppBar.kt b/src/com/android/messaging/ui/conversation/metadata/ui/ConversationTopAppBar.kt
index d82eb9661..47dbced45 100644
--- a/src/com/android/messaging/ui/conversation/metadata/ui/ConversationTopAppBar.kt
+++ b/src/com/android/messaging/ui/conversation/metadata/ui/ConversationTopAppBar.kt
@@ -1,7 +1,8 @@
package com.android.messaging.ui.conversation.metadata.ui
-import androidx.compose.foundation.clickable
+import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
@@ -38,6 +39,8 @@ 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.hapticfeedback.HapticFeedbackType
+import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.pluralStringResource
@@ -72,6 +75,8 @@ import com.android.messaging.ui.core.MessagingPreviewColumn
import com.android.messaging.ui.core.MessagingPreviewTheme
import com.android.messaging.ui.subscription.mapper.resolveDisplayName
import com.android.messaging.util.AccessibilityUtil
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.persistentListOf
private val CONVERSATION_TOP_APP_BAR_TITLE_SPACING = 12.dp
private val CONVERSATION_TOP_APP_BAR_AVATAR_SIZE = 36.dp
@@ -100,6 +105,7 @@ internal fun ConversationTopAppBar(
onSimSelectorClick: () -> Unit = {},
onTitleClick: () -> Unit,
onNavigateBack: () -> Unit,
+ onPhoneNumberCopy: (String) -> Unit = {},
) {
val presentation = rememberConversationTopAppBarPresentation(
metadata = metadata,
@@ -128,6 +134,10 @@ internal fun ConversationTopAppBar(
isClickable = isTitleClickable,
onClick = onTitleClick,
presentation = presentation,
+ phoneNumberCopyTargets = (metadata as? ConversationMetadataUiState.Present)
+ ?.phoneNumberCopyTargets
+ ?: persistentListOf(),
+ onPhoneNumberCopy = onPhoneNumberCopy,
)
},
navigationIcon = {
@@ -197,31 +207,101 @@ private fun ConversationTopAppBarTitle(
isClickable: Boolean,
onClick: () -> Unit,
presentation: ConversationTopAppBarPresentation,
+ phoneNumberCopyTargets: ImmutableList,
+ onPhoneNumberCopy: (String) -> Unit,
) {
- Row(
- modifier = Modifier
- .heightIn(min = TopAppBarDefaults.TopAppBarExpandedHeight)
- .testTag(tag = CONVERSATION_TOP_APP_BAR_TITLE_TEST_TAG)
- .clickable(
- enabled = isClickable,
- onClick = onClick,
+ var isPhoneNumberMenuExpanded by remember { mutableStateOf(value = false) }
+ val hapticFeedback = LocalHapticFeedback.current
+ val copyLabel = stringResource(id = R.string.copy_to_clipboard)
+ val onPhoneNumberAction: (() -> Unit)? = when (phoneNumberCopyTargets.size) {
+ 0 -> null
+ 1 -> {
+ {
+ onPhoneNumberCopy(phoneNumberCopyTargets.single().phoneNumber)
+ }
+ }
+ else -> {
+ {
+ isPhoneNumberMenuExpanded = true
+ }
+ }
+ }
+ val onLongClick = onPhoneNumberAction?.let { action ->
+ {
+ hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
+ action()
+ }
+ }
+
+ Box {
+ Row(
+ modifier = Modifier
+ .heightIn(min = TopAppBarDefaults.TopAppBarExpandedHeight)
+ .testTag(tag = CONVERSATION_TOP_APP_BAR_TITLE_TEST_TAG)
+ .combinedClickable(
+ enabled = isClickable,
+ onClick = onClick,
+ onLongClickLabel = copyLabel.takeIf { onLongClick != null },
+ onLongClick = onLongClick,
+ ),
+ horizontalArrangement = Arrangement.spacedBy(
+ space = CONVERSATION_TOP_APP_BAR_TITLE_SPACING,
),
- horizontalArrangement = Arrangement.spacedBy(
- space = CONVERSATION_TOP_APP_BAR_TITLE_SPACING,
- ),
- verticalAlignment = Alignment.CenterVertically,
- ) {
- ConversationAvatar(
- avatar = presentation.avatar,
- isBlocked = presentation.isBlocked,
- )
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ ConversationAvatar(
+ avatar = presentation.avatar,
+ isBlocked = presentation.isBlocked,
+ )
- ConversationTopAppBarText(
- presentation = presentation,
+ ConversationTopAppBarText(
+ presentation = presentation,
+ )
+ }
+
+ ConversationPhoneNumberCopyMenu(
+ expanded = isPhoneNumberMenuExpanded,
+ targets = phoneNumberCopyTargets,
+ onDismissRequest = { isPhoneNumberMenuExpanded = false },
+ onPhoneNumberCopy = { phoneNumber ->
+ isPhoneNumberMenuExpanded = false
+ onPhoneNumberCopy(phoneNumber)
+ },
)
}
}
+@Composable
+private fun ConversationPhoneNumberCopyMenu(
+ expanded: Boolean,
+ targets: ImmutableList,
+ onDismissRequest: () -> Unit,
+ onPhoneNumberCopy: (String) -> Unit,
+) {
+ DropdownMenu(
+ expanded = expanded,
+ onDismissRequest = onDismissRequest,
+ ) {
+ targets.forEach { target ->
+ DropdownMenuItem(
+ text = {
+ Column {
+ Text(text = target.displayName)
+ if (target.displayName != target.phoneNumber) {
+ Text(
+ text = target.phoneNumber.asLtrText(),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+ },
+ onClick = { onPhoneNumberCopy(target.phoneNumber) },
+ )
+ }
+ }
+}
+
@Composable
private fun ConversationTopAppBarText(
presentation: ConversationTopAppBarPresentation,
diff --git a/src/com/android/messaging/ui/conversation/screen/ConversationScreen.kt b/src/com/android/messaging/ui/conversation/screen/ConversationScreen.kt
index 20a99ee36..996419fc8 100644
--- a/src/com/android/messaging/ui/conversation/screen/ConversationScreen.kt
+++ b/src/com/android/messaging/ui/conversation/screen/ConversationScreen.kt
@@ -1,5 +1,7 @@
package com.android.messaging.ui.conversation.screen
+import android.content.ClipData
+import android.content.ClipboardManager
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHostState
@@ -10,6 +12,7 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.geometry.Rect as ComposeRect
+import androidx.compose.ui.platform.LocalContext
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.android.messaging.data.conversation.model.draft.ConversationDraft
@@ -124,6 +127,15 @@ internal fun ConversationScreenScaffold(
onLockedAudioRecordingStartRequest: () -> Unit,
screenModel: ConversationScreenModel,
) {
+ val context = LocalContext.current
+ val clipboardManager = remember(context) {
+ context.getSystemService(ClipboardManager::class.java)
+ }
+ val onPhoneNumberCopy = remember(clipboardManager) {
+ { phoneNumber: String ->
+ clipboardManager.setPrimaryClip(ClipData.newPlainText(null, phoneNumber))
+ }
+ }
val isSimSelectorAvailable = uiState.composer.simSelector.isAvailable
val simSheetState = rememberConversationSimSheetState(isAvailable = isSimSelectorAvailable)
val showSimSelectorSheet = rememberShowSimSelectorSheetCallback(
@@ -141,6 +153,7 @@ internal fun ConversationScreenScaffold(
onConversationDetailsClick = onConversationDetailsClick,
onNavigateBack = onNavigateBack,
onSimSelectorClick = showSimSelectorSheet,
+ onPhoneNumberCopy = onPhoneNumberCopy,
screenModel = screenModel,
)
},
@@ -173,6 +186,7 @@ internal fun ConversationScreenScaffold(
onMessageDownloadClick = screenModel::onMessageDownloadClick,
onMessageLongClick = screenModel::onMessageLongClick,
onMessageResendClick = screenModel::onMessageResendClick,
+ onPhoneNumberCopy = onPhoneNumberCopy,
onSimSelectorClick = showSimSelectorSheet,
onUnblockClick = screenModel::onUnblockClick,
)
@@ -208,6 +222,7 @@ private fun ConversationScreenTopBar(
onConversationDetailsClick: () -> Unit,
onNavigateBack: () -> Unit,
onSimSelectorClick: () -> Unit,
+ onPhoneNumberCopy: (String) -> Unit,
screenModel: ConversationScreenModel,
) {
when {
@@ -239,6 +254,7 @@ private fun ConversationScreenTopBar(
onShowSubjectFieldClick = screenModel::onShowSubjectFieldClick,
onSimSelectorClick = onSimSelectorClick,
onTitleClick = onConversationDetailsClick,
+ onPhoneNumberCopy = onPhoneNumberCopy,
onNavigateBack = onNavigateBack,
)
}
diff --git a/src/com/android/messaging/ui/conversation/screen/ConversationScreenContent.kt b/src/com/android/messaging/ui/conversation/screen/ConversationScreenContent.kt
index 208c22b62..7bbc4a80c 100644
--- a/src/com/android/messaging/ui/conversation/screen/ConversationScreenContent.kt
+++ b/src/com/android/messaging/ui/conversation/screen/ConversationScreenContent.kt
@@ -66,6 +66,7 @@ internal fun ConversationScreenContent(
onMessageDownloadClick: (String) -> Unit,
onMessageLongClick: (String) -> Unit,
onMessageResendClick: (String) -> Unit,
+ onPhoneNumberCopy: (String) -> Unit = {},
onSimSelectorClick: () -> Unit,
onUnblockClick: () -> Unit,
) {
@@ -113,6 +114,7 @@ internal fun ConversationScreenContent(
onMessageDownloadClick = onMessageDownloadClick,
onMessageLongClick = onMessageLongClick,
onMessageResendClick = onMessageResendClick,
+ onPhoneNumberCopy = onPhoneNumberCopy,
onSimSelectorClick = onSimSelectorClick,
additionalTopContentPadding = messagesTopReservation,
)
@@ -174,6 +176,7 @@ private fun ConversationScreenPresentContent(
onMessageDownloadClick: (String) -> Unit,
onMessageLongClick: (String) -> Unit,
onMessageResendClick: (String) -> Unit,
+ onPhoneNumberCopy: (String) -> Unit,
onSimSelectorClick: () -> Unit,
additionalTopContentPadding: Dp,
) {
@@ -229,6 +232,7 @@ private fun ConversationScreenPresentContent(
onMessageDownloadClick = onMessageDownloadClick,
onMessageLongClick = onMessageLongClick,
onMessageResendClick = onMessageResendClick,
+ onPhoneNumberCopy = onPhoneNumberCopy,
onSimSelectorClick = onSimSelectorClick,
)
}
diff --git a/src/com/android/messaging/ui/conversationlist/chats/ConversationListContent.kt b/src/com/android/messaging/ui/conversationlist/chats/ConversationListContent.kt
index 47889095e..6ba15ab86 100644
--- a/src/com/android/messaging/ui/conversationlist/chats/ConversationListContent.kt
+++ b/src/com/android/messaging/ui/conversationlist/chats/ConversationListContent.kt
@@ -15,6 +15,7 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.android.messaging.R
import com.android.messaging.ui.common.components.PrimaryActionButton
+import com.android.messaging.ui.common.components.participant.PhoneNumberCopyTarget
import com.android.messaging.ui.common.components.reorder.OverlayReorderAnimationController
import com.android.messaging.ui.conversationlist.chats.model.ConversationListAction as Action
import com.android.messaging.ui.conversationlist.common.item.ConversationSwipeKind
@@ -28,6 +29,9 @@ import com.android.messaging.ui.conversationlist.common.support.previewConversat
import com.android.messaging.ui.conversationlist.model.ConversationListContentUiState
import com.android.messaging.ui.conversationlist.model.ConversationListItemUiModel
import com.android.messaging.ui.core.MessagingPreviewTheme
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.ImmutableMap
+import kotlinx.collections.immutable.persistentMapOf
private val ChatSwipeSpec = ConversationListSwipeSpec(
startToEnd = ConversationSwipeKind.ToggleRead,
@@ -43,6 +47,8 @@ internal fun ConversationListContent(
isSelectionMode: Boolean,
fabBottomReserve: Dp,
pinAnimationController: OverlayReorderAnimationController?,
+ phoneNumberCopyTargets: ImmutableMap> =
+ persistentMapOf(),
modifier: Modifier = Modifier,
) {
Box(modifier = modifier.fillMaxSize()) {
@@ -79,6 +85,10 @@ internal fun ConversationListContent(
scaffoldContentPadding = scaffoldContentPadding,
fabBottomReserve = fabBottomReserve,
pinAnimationController = pinAnimationController,
+ phoneNumberCopyTargets = phoneNumberCopyTargets,
+ onAvatarQuickActionsOpen = { conversationId ->
+ onAction(Action.AvatarQuickActionsOpened(conversationId))
+ },
swipeSpec = ChatSwipeSpec,
onItemEvent = { onAction(it.toChatAction()) },
)
diff --git a/src/com/android/messaging/ui/conversationlist/chats/ConversationListScreen.kt b/src/com/android/messaging/ui/conversationlist/chats/ConversationListScreen.kt
index 95d660980..b65ce5f7c 100644
--- a/src/com/android/messaging/ui/conversationlist/chats/ConversationListScreen.kt
+++ b/src/com/android/messaging/ui/conversationlist/chats/ConversationListScreen.kt
@@ -408,6 +408,7 @@ private fun ConversationListScaffold(
isSelectionMode = isSelectionMode,
fabBottomReserve = FabBottomReserve,
pinAnimationController = pinAnimationController,
+ phoneNumberCopyTargets = uiState.phoneNumberCopyTargets,
)
ConversationListFabs(
diff --git a/src/com/android/messaging/ui/conversationlist/chats/ConversationListViewModel.kt b/src/com/android/messaging/ui/conversationlist/chats/ConversationListViewModel.kt
index f720fc2f1..0558237c0 100644
--- a/src/com/android/messaging/ui/conversationlist/chats/ConversationListViewModel.kt
+++ b/src/com/android/messaging/ui/conversationlist/chats/ConversationListViewModel.kt
@@ -2,12 +2,15 @@ package com.android.messaging.ui.conversationlist.chats
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
+import com.android.messaging.data.conversation.repository.ConversationParticipantsRepository
import com.android.messaging.data.conversationlist.model.ConversationListItem
import com.android.messaging.data.conversationlist.model.ConversationListMode
import com.android.messaging.data.conversationlist.model.ConversationListSnapshot
import com.android.messaging.data.conversationlist.repository.ConversationListRepository
import com.android.messaging.data.conversationsettings.model.SnoozeOption
import com.android.messaging.data.debug.DebugFeaturesProvider
+import com.android.messaging.sms.MmsSmsUtils
+import com.android.messaging.ui.common.components.participant.PhoneNumberCopyTarget
import com.android.messaging.ui.conversationlist.chats.mapper.ConversationListUiStateMapper
import com.android.messaging.ui.conversationlist.chats.model.ConversationListAction as Action
import com.android.messaging.ui.conversationlist.chats.model.ConversationListEffect as Effect
@@ -18,6 +21,8 @@ import com.android.messaging.ui.conversationlist.delegate.ConversationListSelect
import com.android.messaging.ui.conversationlist.model.ConversationListAvatarUiModel
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.persistentMapOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
@@ -26,6 +31,7 @@ import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filterNotNull
+import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
@@ -45,11 +51,16 @@ internal class ConversationListViewModel @Inject constructor(
private val actionsDelegate: ConversationListActionsDelegate,
private val optimisticSnapshotDelegate: ConversationListOptimisticSnapshotDelegate,
private val debugFeaturesProvider: DebugFeaturesProvider,
+ private val conversationParticipantsRepository: ConversationParticipantsRepository,
) : ViewModel(),
ConversationListScreenModel {
private val isScrollToTopVisible = MutableStateFlow(false)
private val isDebugEnabled = MutableStateFlow(debugFeaturesProvider.isEnabled())
+ private val phoneNumberCopyTargets = MutableStateFlow(
+ persistentMapOf>(),
+ )
+ private val loadingParticipantConversationIds = mutableSetOf()
private val snapshot: StateFlow = optimisticSnapshotDelegate.snapshot
@@ -61,13 +72,14 @@ internal class ConversationListViewModel @Inject constructor(
selectionDelegate.selectedIds,
isScrollToTopVisible,
isDebugEnabled,
- ) { snapshot, selectedIds, isScrollToTopVisible, isDebugEnabled ->
+ phoneNumberCopyTargets,
+ ) { snapshot, selectedIds, isScrollToTopVisible, isDebugEnabled, copyTargets ->
uiStateMapper.map(
snapshot = snapshot,
selectedConversationIds = selectedIds,
isScrollToTopVisible = isScrollToTopVisible,
isDebugEnabled = isDebugEnabled,
- )
+ ).copy(phoneNumberCopyTargets = copyTargets)
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(
@@ -202,6 +214,10 @@ internal class ConversationListViewModel @Inject constructor(
_effects.trySend(Effect.OpenConversation(action.conversationId))
}
+ is Action.AvatarQuickActionsOpened -> {
+ loadPhoneNumberCopyTargets(action.conversationId)
+ }
+
is Action.AvatarCallClicked -> {
_effects.trySend(Effect.PlaceCall(action.destination))
}
@@ -236,6 +252,47 @@ internal class ConversationListViewModel @Inject constructor(
}
}
+ private fun loadPhoneNumberCopyTargets(conversationId: String) {
+ if (
+ phoneNumberCopyTargets.value.containsKey(conversationId) ||
+ !loadingParticipantConversationIds.add(conversationId)
+ ) {
+ return
+ }
+
+ viewModelScope.launch {
+ try {
+ val participants = conversationParticipantsRepository
+ .getParticipants(conversationId = conversationId)
+ .first()
+ val targets = participants
+ .mapNotNull { participant ->
+ val phoneNumber = participant.destination
+ .trim()
+ .takeIf(String::isNotBlank)
+ ?.takeIf(MmsSmsUtils::isPhoneNumber)
+ ?: return@mapNotNull null
+
+ PhoneNumberCopyTarget(
+ displayName = participant.displayName
+ .takeIf(String::isNotBlank)
+ ?: phoneNumber,
+ phoneNumber = phoneNumber,
+ )
+ }
+ .distinctBy(PhoneNumberCopyTarget::phoneNumber)
+ .toImmutableList()
+
+ phoneNumberCopyTargets.value = phoneNumberCopyTargets.value.put(
+ key = conversationId,
+ value = targets,
+ )
+ } finally {
+ loadingParticipantConversationIds.remove(conversationId)
+ }
+ }
+ }
+
private fun onConversationClick(conversationId: String) {
when {
currentSelectedItems().isNotEmpty() -> {
diff --git a/src/com/android/messaging/ui/conversationlist/chats/model/ConversationListAction.kt b/src/com/android/messaging/ui/conversationlist/chats/model/ConversationListAction.kt
index 24739678c..2ef16853d 100644
--- a/src/com/android/messaging/ui/conversationlist/chats/model/ConversationListAction.kt
+++ b/src/com/android/messaging/ui/conversationlist/chats/model/ConversationListAction.kt
@@ -64,6 +64,10 @@ internal sealed interface ConversationListAction {
val conversationId: String,
) : ListAction
+ data class AvatarQuickActionsOpened(
+ val conversationId: String,
+ ) : ListAction
+
data class AvatarCallClicked(
val destination: String,
) : ListAction
diff --git a/src/com/android/messaging/ui/conversationlist/chats/model/ConversationListUiState.kt b/src/com/android/messaging/ui/conversationlist/chats/model/ConversationListUiState.kt
index ed3e57090..342e46592 100644
--- a/src/com/android/messaging/ui/conversationlist/chats/model/ConversationListUiState.kt
+++ b/src/com/android/messaging/ui/conversationlist/chats/model/ConversationListUiState.kt
@@ -1,7 +1,11 @@
package com.android.messaging.ui.conversationlist.chats.model
import androidx.compose.runtime.Immutable
+import com.android.messaging.ui.common.components.participant.PhoneNumberCopyTarget
import com.android.messaging.ui.conversationlist.model.ConversationListContentUiState
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.ImmutableMap
+import kotlinx.collections.immutable.persistentMapOf
@Immutable
internal data class ConversationListUiState(
@@ -10,4 +14,6 @@ internal data class ConversationListUiState(
val isScrollToTopVisible: Boolean = false,
val hasBlockedParticipants: Boolean = false,
val isDebugEnabled: Boolean = false,
+ val phoneNumberCopyTargets: ImmutableMap> =
+ persistentMapOf(),
)
diff --git a/src/com/android/messaging/ui/conversationlist/common/item/ConversationListItemAvatar.kt b/src/com/android/messaging/ui/conversationlist/common/item/ConversationListItemAvatar.kt
index 19906cb6f..56026a2ab 100644
--- a/src/com/android/messaging/ui/conversationlist/common/item/ConversationListItemAvatar.kt
+++ b/src/com/android/messaging/ui/conversationlist/common/item/ConversationListItemAvatar.kt
@@ -1,5 +1,7 @@
package com.android.messaging.ui.conversationlist.common.item
+import android.content.ClipData
+import android.content.ClipboardManager
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
@@ -15,6 +17,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.clearAndSetSemantics
@@ -22,17 +25,23 @@ import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.onClick
import androidx.compose.ui.semantics.role
import com.android.messaging.R
+import com.android.messaging.sms.MmsSmsUtils
import com.android.messaging.ui.common.components.participant.ParticipantQuickActionsPopup
+import com.android.messaging.ui.common.components.participant.PhoneNumberCopyTarget
import com.android.messaging.ui.common.components.participant.participantAvatarLabel
import com.android.messaging.ui.common.components.participant.participantColorSeed
import com.android.messaging.ui.common.components.selection.SelectionListAvatar
import com.android.messaging.ui.conversationlist.model.ConversationListItemUiModel
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.persistentListOf
@Composable
internal fun ConversationListItemAvatar(
item: ConversationListItemUiModel,
isSelectionMode: Boolean,
onToggleSelection: () -> Unit,
+ phoneNumberCopyTargets: ImmutableList,
+ onQuickActionsOpen: () -> Unit,
onMessageClick: () -> Unit,
onCallClick: (() -> Unit)?,
onContactClick: (() -> Unit)?,
@@ -56,7 +65,10 @@ internal fun ConversationListItemAvatar(
val onAvatarClick = {
when {
isSelectionMode -> onToggleSelection()
- else -> showQuickActions = true
+ else -> {
+ onQuickActionsOpen()
+ showQuickActions = true
+ }
}
}
val avatarContentDescription = stringResource(
@@ -66,6 +78,22 @@ internal fun ConversationListItemAvatar(
},
item.title.orEmpty(),
)
+ val fallbackCopyTarget = item.avatar.normalizedDestination
+ ?.takeIf(String::isNotBlank)
+ ?.takeIf(MmsSmsUtils::isPhoneNumber)
+ ?.let { phoneNumber ->
+ PhoneNumberCopyTarget(
+ displayName = item.title?.takeIf(String::isNotBlank) ?: phoneNumber,
+ phoneNumber = phoneNumber,
+ )
+ }
+ val resolvedCopyTargets = phoneNumberCopyTargets.takeIf { it.isNotEmpty() }
+ ?: fallbackCopyTarget?.let { persistentListOf(it) }
+ ?: persistentListOf()
+ val context = LocalContext.current
+ val clipboardManager = remember(context) {
+ context.getSystemService(ClipboardManager::class.java)
+ }
Box(modifier = Modifier.size(ItemAvatarSize)) {
SelectionListAvatar(
@@ -89,6 +117,12 @@ internal fun ConversationListItemAvatar(
fallbackIcon = fallbackIcon,
fallbackLabel = fallbackLabel,
colorSeedCode = colorSeedCode,
+ phoneNumberCopyTargets = resolvedCopyTargets,
+ onPhoneNumberCopy = { phoneNumber ->
+ clipboardManager?.setPrimaryClip(
+ ClipData.newPlainText(null, phoneNumber),
+ )
+ },
onDismiss = { showQuickActions = false },
onMessageClick = onMessageClick,
onCallClick = onCallClick,
@@ -120,6 +154,8 @@ private fun ConversationListAvatarQuickActions(
fallbackIcon: ImageVector,
fallbackLabel: String?,
colorSeedCode: String?,
+ phoneNumberCopyTargets: ImmutableList,
+ onPhoneNumberCopy: (String) -> Unit,
onDismiss: () -> Unit,
onMessageClick: () -> Unit,
onCallClick: (() -> Unit)?,
@@ -152,5 +188,10 @@ private fun ConversationListAvatarQuickActions(
onDismiss()
},
isContactSaved = item.avatar.isContactSaved,
+ phoneNumberCopyTargets = phoneNumberCopyTargets,
+ onPhoneNumberCopy = { phoneNumber ->
+ onPhoneNumberCopy(phoneNumber)
+ onDismiss()
+ },
)
}
diff --git a/src/com/android/messaging/ui/conversationlist/common/item/ConversationListItemRow.kt b/src/com/android/messaging/ui/conversationlist/common/item/ConversationListItemRow.kt
index aa0ae415a..6832cc8ee 100644
--- a/src/com/android/messaging/ui/conversationlist/common/item/ConversationListItemRow.kt
+++ b/src/com/android/messaging/ui/conversationlist/common/item/ConversationListItemRow.kt
@@ -32,12 +32,15 @@ import androidx.compose.ui.unit.dp
import com.android.messaging.R
import com.android.messaging.data.conversationlist.model.ConversationListMessageStatus
import com.android.messaging.ui.common.components.TwoLineListItem
+import com.android.messaging.ui.common.components.participant.PhoneNumberCopyTarget
import com.android.messaging.ui.conversationlist.common.support.conversationListItemTestTag
import com.android.messaging.ui.conversationlist.common.support.previewConversationListItem
import com.android.messaging.ui.conversationlist.model.ConversationListItemUiModel
import com.android.messaging.ui.conversationlist.model.ConversationListPreviewUiModel
import com.android.messaging.ui.core.MessagingPreviewColumn
import com.android.messaging.util.Dates
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.persistentListOf
@Composable
internal fun ConversationListItemRow(
@@ -46,6 +49,8 @@ internal fun ConversationListItemRow(
onLongClick: () -> Unit,
modifier: Modifier = Modifier,
isSelectionMode: Boolean = false,
+ phoneNumberCopyTargets: ImmutableList = persistentListOf(),
+ onAvatarQuickActionsOpen: () -> Unit = {},
onAvatarMessageClick: () -> Unit = {},
onAvatarCallClick: (() -> Unit)? = null,
onAvatarContactClick: (() -> Unit)? = null,
@@ -58,6 +63,8 @@ internal fun ConversationListItemRow(
item = item,
isSelectionMode = isSelectionMode,
onToggleSelection = onClick,
+ phoneNumberCopyTargets = phoneNumberCopyTargets,
+ onQuickActionsOpen = onAvatarQuickActionsOpen,
onMessageClick = onAvatarMessageClick,
onCallClick = onAvatarCallClick,
onContactClick = onAvatarContactClick,
diff --git a/src/com/android/messaging/ui/conversationlist/common/list/ConversationListItems.kt b/src/com/android/messaging/ui/conversationlist/common/list/ConversationListItems.kt
index c8d8850d3..e10921681 100644
--- a/src/com/android/messaging/ui/conversationlist/common/list/ConversationListItems.kt
+++ b/src/com/android/messaging/ui/conversationlist/common/list/ConversationListItems.kt
@@ -26,6 +26,7 @@ import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
import com.android.messaging.ui.common.components.horizontalSafeDrawingInsets
+import com.android.messaging.ui.common.components.participant.PhoneNumberCopyTarget
import com.android.messaging.ui.common.components.reorder.OverlayReorderAnimationController
import com.android.messaging.ui.conversationlist.common.item.ConversationListItemRow
import com.android.messaging.ui.conversationlist.common.item.ConversationSwipeAction
@@ -36,7 +37,10 @@ import com.android.messaging.ui.conversationlist.common.support.CONVERSATION_LIS
import com.android.messaging.ui.conversationlist.common.support.rememberAppearanceAnimationTokens
import com.android.messaging.ui.conversationlist.model.ConversationListItemUiModel
import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.ImmutableMap
import kotlinx.collections.immutable.ImmutableSet
+import kotlinx.collections.immutable.persistentListOf
+import kotlinx.collections.immutable.persistentMapOf
private const val CONVERSATION_ROW_CONTENT_TYPE = "conversation_row"
@@ -61,6 +65,9 @@ internal fun ConversationListItems(
scaffoldContentPadding: PaddingValues,
fabBottomReserve: Dp,
pinAnimationController: OverlayReorderAnimationController?,
+ phoneNumberCopyTargets: ImmutableMap> =
+ persistentMapOf(),
+ onAvatarQuickActionsOpen: (String) -> Unit = {},
swipeSpec: ConversationListSwipeSpec,
onItemEvent: (ConversationListItemEvent) -> Unit,
) {
@@ -118,6 +125,8 @@ internal fun ConversationListItems(
}
},
swipeSpec = swipeSpec,
+ phoneNumberCopyTargets = phoneNumberCopyTargets,
+ onAvatarQuickActionsOpen = onAvatarQuickActionsOpen,
onItemEvent = onItemEvent,
)
}
@@ -134,6 +143,8 @@ private fun LazyItemScope.ConversationListRow(
pinAnimationController: OverlayReorderAnimationController?,
onAppearanceAnimationFinished: () -> Unit,
swipeSpec: ConversationListSwipeSpec,
+ phoneNumberCopyTargets: ImmutableMap>,
+ onAvatarQuickActionsOpen: (String) -> Unit,
onItemEvent: (ConversationListItemEvent) -> Unit,
) {
val isHiddenByPinAnimation = pinAnimationController?.isItemHidden(item.conversationId) == true
@@ -186,6 +197,9 @@ private fun LazyItemScope.ConversationListRow(
item = item,
isSelectionMode = isSelectionMode,
horizontalInsets = horizontalInsets,
+ phoneNumberCopyTargets = phoneNumberCopyTargets[item.conversationId]
+ ?: persistentListOf(),
+ onAvatarQuickActionsOpen = onAvatarQuickActionsOpen,
onItemEvent = onItemEvent,
)
}
@@ -196,6 +210,8 @@ private fun ConversationListItemContent(
item: ConversationListItemUiModel,
isSelectionMode: Boolean,
horizontalInsets: PaddingValues,
+ phoneNumberCopyTargets: ImmutableList,
+ onAvatarQuickActionsOpen: (String) -> Unit,
onItemEvent: (ConversationListItemEvent) -> Unit,
) {
val destination = item.avatar.normalizedDestination
@@ -210,6 +226,10 @@ private fun ConversationListItemContent(
onItemEvent(ConversationListItemEvent.LongClicked(item.conversationId))
},
isSelectionMode = isSelectionMode,
+ phoneNumberCopyTargets = phoneNumberCopyTargets,
+ onAvatarQuickActionsOpen = {
+ onAvatarQuickActionsOpen(item.conversationId)
+ },
onAvatarMessageClick = {
onItemEvent(ConversationListItemEvent.AvatarMessageClicked(item.conversationId))
},