From b1b988924645eaa77b2e076a86959cf9d5b0f448 Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Tue, 24 Mar 2026 20:51:23 +0200 Subject: [PATCH 01/99] Add conversation messages repository --- .../repository/ConversationsRepository.kt | 70 +++++++++++++++++++ .../conversation/ConversationBindsModule.kt | 27 +++++++ .../messaging/util/db/ReversedCursor.kt | 62 ++++++++++++++++ 3 files changed, 159 insertions(+) create mode 100644 src/com/android/messaging/data/conversation/repository/ConversationsRepository.kt create mode 100644 src/com/android/messaging/di/conversation/ConversationBindsModule.kt create mode 100644 src/com/android/messaging/util/db/ReversedCursor.kt diff --git a/src/com/android/messaging/data/conversation/repository/ConversationsRepository.kt b/src/com/android/messaging/data/conversation/repository/ConversationsRepository.kt new file mode 100644 index 00000000..fe86138c --- /dev/null +++ b/src/com/android/messaging/data/conversation/repository/ConversationsRepository.kt @@ -0,0 +1,70 @@ +package com.android.messaging.data.conversation.repository + +import android.content.ContentResolver +import android.database.ContentObserver +import android.net.Uri +import com.android.messaging.datamodel.MessagingContentProvider +import com.android.messaging.datamodel.data.ConversationMessageData +import com.android.messaging.di.core.IoDispatcher +import com.android.messaging.util.db.ReversedCursor +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.conflate +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import javax.inject.Inject + +interface ConversationsRepository { + fun getConversationMessages(conversationId: String): Flow> +} + +internal class ConversationsRepositoryImpl @Inject constructor( + private val contentResolver: ContentResolver, + @param:IoDispatcher + private val ioDispatcher: CoroutineDispatcher, +) : ConversationsRepository { + + override fun getConversationMessages(conversationId: String): Flow> { + val uri = MessagingContentProvider.buildConversationMessagesUri(conversationId) + return callbackFlow { + val observer = object : ContentObserver(null) { + override fun onChange(selfChange: Boolean) { + trySend(Unit) + } + } + contentResolver.registerContentObserver(uri, true, observer) + + trySend(Unit) + + awaitClose { + contentResolver.unregisterContentObserver(observer) + } + } + .conflate() + .map { + queryConversationMessages(uri = uri) + } + .flowOn(ioDispatcher) + } + + private fun queryConversationMessages(uri: Uri): List { + return contentResolver + .query( + uri, + ConversationMessageData.getProjection(), + null, null, null, + ) + ?.use { rawCursor -> + val reversedCursor = ReversedCursor(cursor = rawCursor) + + buildList { + while (reversedCursor.moveToNext()) { + add(ConversationMessageData().apply { bind(reversedCursor) }) + } + } + } + ?: emptyList() + } +} diff --git a/src/com/android/messaging/di/conversation/ConversationBindsModule.kt b/src/com/android/messaging/di/conversation/ConversationBindsModule.kt new file mode 100644 index 00000000..7c9af8ea --- /dev/null +++ b/src/com/android/messaging/di/conversation/ConversationBindsModule.kt @@ -0,0 +1,27 @@ +package com.android.messaging.di.conversation + +import com.android.messaging.data.conversation.repository.ConversationsRepository +import com.android.messaging.data.conversation.repository.ConversationsRepositoryImpl +import com.android.messaging.ui.conversation.v2.mapper.ConversationMessageUiModelMapper +import com.android.messaging.ui.conversation.v2.mapper.ConversationMessageUiModelMapperImpl +import dagger.Binds +import dagger.Module +import dagger.Reusable +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +@Module +@InstallIn(SingletonComponent::class) +internal abstract class ConversationBindsModule { + + @Binds + @Reusable + abstract fun provideConversationsRepository( + impl: ConversationsRepositoryImpl, + ): ConversationsRepository + + @Binds + abstract fun provideConversationMessageUiModelMapper( + impl: ConversationMessageUiModelMapperImpl, + ): ConversationMessageUiModelMapper +} diff --git a/src/com/android/messaging/util/db/ReversedCursor.kt b/src/com/android/messaging/util/db/ReversedCursor.kt new file mode 100644 index 00000000..5984eda4 --- /dev/null +++ b/src/com/android/messaging/util/db/ReversedCursor.kt @@ -0,0 +1,62 @@ +package com.android.messaging.util.db + +import android.database.Cursor +import android.database.CursorWrapper + +/** + * Presents a cursor in reverse row order while preserving standard cursor navigation semantics + * Will replace [com.android.messaging.datamodel.data.ConversationData.ReversedCursor] in the future + */ +internal class ReversedCursor( + cursor: Cursor, +) : CursorWrapper(cursor) { + private val count: Int = cursor.count + + init { + cursor.moveToPosition(count) + } + + override fun moveToPosition(position: Int): Boolean { + return super.moveToPosition(count - position - 1) + } + + override fun getPosition(): Int { + return count - super.getPosition() - 1 + } + + override fun isAfterLast(): Boolean { + return super.isBeforeFirst + } + + override fun isBeforeFirst(): Boolean { + return super.isAfterLast + } + + override fun isFirst(): Boolean { + return super.isLast + } + + override fun isLast(): Boolean { + return super.isFirst + } + + override fun move(offset: Int): Boolean { + return super.move(-offset) + } + + override fun moveToFirst(): Boolean { + return super.moveToLast() + } + + override fun moveToLast(): Boolean { + return super.moveToFirst() + } + + override fun moveToNext(): Boolean { + return super.moveToPrevious() + } + + override fun moveToPrevious(): Boolean { + return super.moveToNext() + } +} From 768140548651011087ac49951d218a74ec064814 Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Tue, 24 Mar 2026 20:51:44 +0200 Subject: [PATCH 02/99] Add Compose conversation message models and list components --- .../v2/component/ConversationMessage.kt | 155 ++++++++++++++++++ .../v2/component/ConversationMessages.kt | 96 +++++++++++ .../ConversationMessageUiModelMapper.kt | 105 ++++++++++++ .../model/ConversationMessagePartUiModel.kt | 13 ++ .../v2/model/ConversationMessageUiModel.kt | 60 +++++++ .../v2/model/ConversationUiState.kt | 20 +++ 6 files changed, 449 insertions(+) create mode 100644 src/com/android/messaging/ui/conversation/v2/component/ConversationMessage.kt create mode 100644 src/com/android/messaging/ui/conversation/v2/component/ConversationMessages.kt create mode 100644 src/com/android/messaging/ui/conversation/v2/mapper/ConversationMessageUiModelMapper.kt create mode 100644 src/com/android/messaging/ui/conversation/v2/model/ConversationMessagePartUiModel.kt create mode 100644 src/com/android/messaging/ui/conversation/v2/model/ConversationMessageUiModel.kt create mode 100644 src/com/android/messaging/ui/conversation/v2/model/ConversationUiState.kt diff --git a/src/com/android/messaging/ui/conversation/v2/component/ConversationMessage.kt b/src/com/android/messaging/ui/conversation/v2/component/ConversationMessage.kt new file mode 100644 index 00000000..6a4b3602 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/v2/component/ConversationMessage.kt @@ -0,0 +1,155 @@ +package com.android.messaging.ui.conversation.v2.component + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.android.messaging.ui.conversation.v2.model.ConversationMessagePartUiModel +import com.android.messaging.ui.conversation.v2.model.ConversationMessageUiModel + +@Composable +internal fun ConversationMessage( + modifier: Modifier = Modifier, + message: ConversationMessageUiModel, +) { + val horizontalArrangement = if (message.isIncoming) { + Arrangement.Start + } else { + Arrangement.End + } + val bubbleColor = if (message.isIncoming) { + MaterialTheme.colorScheme.surfaceVariant + } else { + MaterialTheme.colorScheme.primaryContainer + } + val bubbleShape = messageBubbleShape(message = message) + val messageBody = buildMessageBody(message = message) + + Row( + modifier = modifier.fillMaxWidth(), + horizontalArrangement = horizontalArrangement, + ) { + Surface( + color = bubbleColor, + shape = bubbleShape, + modifier = Modifier.fillMaxWidth(fraction = 0.8f), + ) { + Column( + modifier = Modifier.padding(all = 12.dp), + verticalArrangement = Arrangement.spacedBy(space = 4.dp), + ) { + if (message.isIncoming && !message.senderDisplayName.isNullOrBlank()) { + Text( + text = message.senderDisplayName, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + + Text( + text = messageBody, + style = MaterialTheme.typography.bodyLarge, + ) + } + } + } +} + +private fun messageBubbleShape(message: ConversationMessageUiModel): RoundedCornerShape { + val cornerRadius = 20.dp + val topCornerRadius = if (message.canClusterWithPrevious) { + 0.dp + } else { + cornerRadius + } + val bottomCornerRadius = if (message.canClusterWithNext) { + 0.dp + } else { + cornerRadius + } + + return RoundedCornerShape( + topStart = topCornerRadius, + topEnd = topCornerRadius, + bottomStart = bottomCornerRadius, + bottomEnd = bottomCornerRadius, + ) +} + +private fun buildMessageBody(message: ConversationMessageUiModel): String { + val text = message.text?.takeIf { value -> + value.isNotBlank() + } + if (text != null) { + return text + } + + val subject = message.mmsSubject?.takeIf { value -> + value.isNotBlank() + } + if (subject != null) { + return subject + } + + val partText = message.parts.firstNotNullOfOrNull { part -> + part.text?.takeIf { value -> + value.isNotBlank() + } + } + if (partText != null) { + return partText + } + + return message.parts.firstOrNull()?.contentType.orEmpty() +} + + +private fun previewMessage( + messageId: String, + text: String, + isIncoming: Boolean, + senderDisplayName: String?, + canClusterWithPrevious: Boolean, + canClusterWithNext: Boolean, +): ConversationMessageUiModel { + return ConversationMessageUiModel( + messageId = messageId, + conversationId = "preview-conversation", + text = text, + parts = listOf( + ConversationMessagePartUiModel( + contentType = "text/plain", + text = text, + contentUri = null, + width = 0, + height = 0, + ), + ), + sentTimestamp = 0L, + receivedTimestamp = 0L, + status = if (isIncoming) { + ConversationMessageUiModel.Status.Incoming.Complete + } else { + ConversationMessageUiModel.Status.Outgoing.Complete + }, + isIncoming = isIncoming, + senderDisplayName = senderDisplayName, + senderAvatarUri = null, + senderContactLookupKey = null, + canClusterWithPrevious = canClusterWithPrevious, + canClusterWithNext = canClusterWithNext, + mmsSubject = null, + protocol = ConversationMessageUiModel.Protocol.SMS, + ) +} diff --git a/src/com/android/messaging/ui/conversation/v2/component/ConversationMessages.kt b/src/com/android/messaging/ui/conversation/v2/component/ConversationMessages.kt new file mode 100644 index 00000000..48d5d7b2 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/v2/component/ConversationMessages.kt @@ -0,0 +1,96 @@ +package com.android.messaging.ui.conversation.v2.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.runtime.Composable +import androidx.compose.material3.MaterialTheme +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.android.messaging.ui.conversation.v2.model.ConversationMessagePartUiModel +import com.android.messaging.ui.conversation.v2.model.ConversationMessageUiModel + +@Composable +internal fun ConversationMessages( + modifier: Modifier = Modifier, + messages: List, + listState: LazyListState, +) { + LazyColumn( + state = listState, + modifier = modifier + .fillMaxSize() + .background(color = MaterialTheme.colorScheme.background), + contentPadding = PaddingValues(all = 16.dp), + ) { + itemsIndexed( + items = messages, + key = { _, message -> message.messageId }, + ) { index, message -> + ConversationMessage( + modifier = Modifier.padding(top = messageItemTopPadding(index = index, message = message)), + message = message, + ) + } + } +} + +private fun messageItemTopPadding( + index: Int, + message: ConversationMessageUiModel, +): Dp { + if (index == 0) { + return 0.dp + } + + return if (message.canClusterWithPrevious) { + 2.dp + } else { + 8.dp + } +} + + +private fun previewMessage( + messageId: String, + text: String, + isIncoming: Boolean, + senderDisplayName: String?, + canClusterWithPrevious: Boolean, + canClusterWithNext: Boolean, +): ConversationMessageUiModel { + return ConversationMessageUiModel( + messageId = messageId, + conversationId = "preview-conversation", + text = text, + parts = listOf( + ConversationMessagePartUiModel( + contentType = "text/plain", + text = text, + contentUri = null, + width = 0, + height = 0, + ), + ), + sentTimestamp = 0L, + receivedTimestamp = 0L, + status = if (isIncoming) { + ConversationMessageUiModel.Status.Incoming.Complete + } else { + ConversationMessageUiModel.Status.Outgoing.Complete + }, + isIncoming = isIncoming, + senderDisplayName = senderDisplayName, + senderAvatarUri = null, + senderContactLookupKey = null, + canClusterWithPrevious = canClusterWithPrevious, + canClusterWithNext = canClusterWithNext, + mmsSubject = null, + protocol = ConversationMessageUiModel.Protocol.SMS, + ) +} diff --git a/src/com/android/messaging/ui/conversation/v2/mapper/ConversationMessageUiModelMapper.kt b/src/com/android/messaging/ui/conversation/v2/mapper/ConversationMessageUiModelMapper.kt new file mode 100644 index 00000000..91bb8294 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/v2/mapper/ConversationMessageUiModelMapper.kt @@ -0,0 +1,105 @@ +package com.android.messaging.ui.conversation.v2.mapper + +import android.util.Log +import com.android.messaging.datamodel.data.ConversationMessageData +import com.android.messaging.datamodel.data.MessageData +import com.android.messaging.datamodel.data.MessagePartData +import com.android.messaging.ui.conversation.v2.model.ConversationMessagePartUiModel +import com.android.messaging.ui.conversation.v2.model.ConversationMessageUiModel +import javax.inject.Inject + +internal interface ConversationMessageUiModelMapper { + fun map(data: ConversationMessageData): ConversationMessageUiModel? +} + +internal class ConversationMessageUiModelMapperImpl @Inject constructor() : ConversationMessageUiModelMapper { + + // TODO: Check if empty default values are ok + override fun map(data: ConversationMessageData): ConversationMessageUiModel? { + return ConversationMessageUiModel( + messageId = data.messageId ?: "", + conversationId = data.conversationId ?: "", + text = data.text, + parts = data.parts?.map(::mapPart) ?: emptyList(), + sentTimestamp = data.sentTimeStamp, + receivedTimestamp = data.receivedTimeStamp, + status = mapStatus(data.status), + isIncoming = data.isIncoming, + senderDisplayName = data.senderDisplayName, + senderAvatarUri = data.senderProfilePhotoUri, + senderContactLookupKey = data.senderContactLookupKey, + canClusterWithPrevious = data.canClusterWithPreviousMessage, + canClusterWithNext = data.canClusterWithNextMessage, + mmsSubject = data.mmsSubject, + protocol = mapProtocol(data), + ) + } + + private fun mapPart(part: MessagePartData): ConversationMessagePartUiModel { + return ConversationMessagePartUiModel( + contentType = part.contentType ?: "", + text = part.text, + contentUri = part.contentUri, + width = part.width, + height = part.height, + ) + } + + private fun mapStatus(javaStatus: Int): ConversationMessageUiModel.Status { + return when (javaStatus) { + MessageData.BUGLE_STATUS_UNKNOWN -> ConversationMessageUiModel.Status.Unknown + + MessageData.BUGLE_STATUS_OUTGOING_COMPLETE -> ConversationMessageUiModel.Status.Outgoing.Complete + MessageData.BUGLE_STATUS_OUTGOING_DELIVERED -> ConversationMessageUiModel.Status.Outgoing.Delivered + MessageData.BUGLE_STATUS_OUTGOING_DRAFT -> ConversationMessageUiModel.Status.Outgoing.Draft + MessageData.BUGLE_STATUS_OUTGOING_YET_TO_SEND -> ConversationMessageUiModel.Status.Outgoing.YetToSend + MessageData.BUGLE_STATUS_OUTGOING_SENDING -> ConversationMessageUiModel.Status.Outgoing.Sending + MessageData.BUGLE_STATUS_OUTGOING_RESENDING -> ConversationMessageUiModel.Status.Outgoing.Resending + MessageData.BUGLE_STATUS_OUTGOING_AWAITING_RETRY -> ConversationMessageUiModel.Status.Outgoing.AwaitingRetry + MessageData.BUGLE_STATUS_OUTGOING_FAILED -> ConversationMessageUiModel.Status.Outgoing.Failed + MessageData.BUGLE_STATUS_OUTGOING_FAILED_EMERGENCY_NUMBER -> + ConversationMessageUiModel.Status.Outgoing.FailedEmergencyNumber + + MessageData.BUGLE_STATUS_INCOMING_COMPLETE -> ConversationMessageUiModel.Status.Incoming.Complete + MessageData.BUGLE_STATUS_INCOMING_YET_TO_MANUAL_DOWNLOAD -> + ConversationMessageUiModel.Status.Incoming.YetToManualDownload + + MessageData.BUGLE_STATUS_INCOMING_RETRYING_MANUAL_DOWNLOAD -> + ConversationMessageUiModel.Status.Incoming.RetryingManualDownload + + MessageData.BUGLE_STATUS_INCOMING_MANUAL_DOWNLOADING -> + ConversationMessageUiModel.Status.Incoming.ManualDownloading + + MessageData.BUGLE_STATUS_INCOMING_RETRYING_AUTO_DOWNLOAD -> + ConversationMessageUiModel.Status.Incoming.RetryingAutoDownload + + MessageData.BUGLE_STATUS_INCOMING_AUTO_DOWNLOADING -> + ConversationMessageUiModel.Status.Incoming.AutoDownloading + + MessageData.BUGLE_STATUS_INCOMING_DOWNLOAD_FAILED -> + ConversationMessageUiModel.Status.Incoming.DownloadFailed + + MessageData.BUGLE_STATUS_INCOMING_EXPIRED_OR_NOT_AVAILABLE -> + ConversationMessageUiModel.Status.Incoming.ExpiredOrNotAvailable + + else -> { + Log.e(LOG_TAG, "mapStatus: unexpected value=$javaStatus") + + ConversationMessageUiModel.Status.Unknown + } + } + } + + private fun mapProtocol(data: ConversationMessageData): ConversationMessageUiModel.Protocol { + return when { + data.isSms -> ConversationMessageUiModel.Protocol.SMS + data.isMmsNotification -> ConversationMessageUiModel.Protocol.MMS_PUSH_NOTIFICATION + data.isMms -> ConversationMessageUiModel.Protocol.MMS + else -> ConversationMessageUiModel.Protocol.UNKNOWN + } + } + + private companion object { + private const val LOG_TAG = "ConversationMessageUiModelMapper" + } +} diff --git a/src/com/android/messaging/ui/conversation/v2/model/ConversationMessagePartUiModel.kt b/src/com/android/messaging/ui/conversation/v2/model/ConversationMessagePartUiModel.kt new file mode 100644 index 00000000..a273b39c --- /dev/null +++ b/src/com/android/messaging/ui/conversation/v2/model/ConversationMessagePartUiModel.kt @@ -0,0 +1,13 @@ +package com.android.messaging.ui.conversation.v2.model + +import android.net.Uri +import androidx.compose.runtime.Immutable + +@Immutable +internal data class ConversationMessagePartUiModel( + val contentType: String, + val text: String?, + val contentUri: Uri?, + val width: Int, + val height: Int, +) diff --git a/src/com/android/messaging/ui/conversation/v2/model/ConversationMessageUiModel.kt b/src/com/android/messaging/ui/conversation/v2/model/ConversationMessageUiModel.kt new file mode 100644 index 00000000..b7a1bffd --- /dev/null +++ b/src/com/android/messaging/ui/conversation/v2/model/ConversationMessageUiModel.kt @@ -0,0 +1,60 @@ +package com.android.messaging.ui.conversation.v2.model + +import android.net.Uri +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.Stable + +@Immutable +internal data class ConversationMessageUiModel( + val messageId: String, + val conversationId: String, + val text: String?, + val parts: List, + val sentTimestamp: Long, + val receivedTimestamp: Long, + val status: Status, + val isIncoming: Boolean, + val senderDisplayName: String?, + val senderAvatarUri: Uri?, + val senderContactLookupKey: String?, + val canClusterWithPrevious: Boolean, + val canClusterWithNext: Boolean, + val mmsSubject: String?, + val protocol: Protocol, +) { + + @Stable + sealed interface Status { + data object Unknown : Status + + sealed interface Outgoing : Status { + data object Complete : Outgoing + data object Delivered : Outgoing + data object Draft : Outgoing + data object YetToSend : Outgoing + data object Sending : Outgoing + data object Resending : Outgoing + data object AwaitingRetry : Outgoing + data object Failed : Outgoing + data object FailedEmergencyNumber : Outgoing + } + + sealed interface Incoming : Status { + data object Complete : Incoming + data object YetToManualDownload : Incoming + data object RetryingManualDownload : Incoming + data object ManualDownloading : Incoming + data object RetryingAutoDownload : Incoming + data object AutoDownloading : Incoming + data object DownloadFailed : Incoming + data object ExpiredOrNotAvailable : Incoming + } + } + + enum class Protocol { + UNKNOWN, + SMS, + MMS, + MMS_PUSH_NOTIFICATION, + } +} diff --git a/src/com/android/messaging/ui/conversation/v2/model/ConversationUiState.kt b/src/com/android/messaging/ui/conversation/v2/model/ConversationUiState.kt new file mode 100644 index 00000000..675da871 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/v2/model/ConversationUiState.kt @@ -0,0 +1,20 @@ +package com.android.messaging.ui.conversation.v2.model + +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.Stable + +@Stable +internal sealed interface ConversationUiState { + + data object Loading : ConversationUiState + + @Immutable + data class Present( + val conversationName: String = "", + val selfParticipantId: String = "", + val isGroupConversation: Boolean = false, + val messages: List = emptyList(), + // TODO: Draft + + ) : ConversationUiState +} From 04eeb3b28efd6a53caca6ced417925eca863ce83 Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Tue, 24 Mar 2026 20:51:59 +0200 Subject: [PATCH 03/99] Wire Compose conversation screen and activity --- AndroidManifest.xml | 14 +++ .../conversation/v2/ConversationActivity.kt | 45 ++++++++++ .../ui/conversation/v2/ConversationScreen.kt | 89 +++++++++++++++++++ .../conversation/v2/ConversationViewModel.kt | 81 +++++++++++++++++ 4 files changed, 229 insertions(+) create mode 100644 src/com/android/messaging/ui/conversation/v2/ConversationActivity.kt create mode 100644 src/com/android/messaging/ui/conversation/v2/ConversationScreen.kt create mode 100644 src/com/android/messaging/ui/conversation/v2/ConversationViewModel.kt diff --git a/AndroidManifest.xml b/AndroidManifest.xml index a1698f89..be455c56 100644 --- a/AndroidManifest.xml +++ b/AndroidManifest.xml @@ -139,6 +139,20 @@ android:value="com.android.messaging.ui.conversationlist.ConversationListActivity" /> + + + + + ConversationScreenContent( + modifier = modifier + .padding(contentPadding), + conversationId = conversationId, + uiState = uiState.value, + ) + } +} + +@Composable +private fun ConversationScreenContent( + modifier: Modifier = Modifier, + conversationId: String?, + uiState: ConversationUiState, +) { + when (uiState) { + ConversationUiState.Loading -> { + Box( + modifier = modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } + } + + is ConversationUiState.Present -> { + val messagesListState = rememberMessagesListState( + conversationId = conversationId, + initialMessageIndex = uiState.messages.lastIndex.coerceAtLeast(minimumValue = 0), + ) + + ConversationMessages( + modifier = modifier, + messages = uiState.messages, + listState = messagesListState, + ) + } + } +} + +@Composable +private fun rememberMessagesListState( + conversationId: String?, + initialMessageIndex: Int, +): LazyListState { + return rememberSaveable( + conversationId, + saver = LazyListState.Saver, + ) { + LazyListState( + firstVisibleItemIndex = initialMessageIndex, + firstVisibleItemScrollOffset = 0, + ) + } +} diff --git a/src/com/android/messaging/ui/conversation/v2/ConversationViewModel.kt b/src/com/android/messaging/ui/conversation/v2/ConversationViewModel.kt new file mode 100644 index 00000000..8bd81550 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/v2/ConversationViewModel.kt @@ -0,0 +1,81 @@ +package com.android.messaging.ui.conversation.v2 + +import android.util.Log +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.android.messaging.data.conversation.repository.ConversationsRepository +import com.android.messaging.di.core.DefaultDispatcher +import com.android.messaging.ui.conversation.v2.mapper.ConversationMessageUiModelMapper +import com.android.messaging.ui.conversation.v2.model.ConversationUiState +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import javax.inject.Inject + +@HiltViewModel +internal class ConversationViewModel @Inject constructor( + private val conversationsRepository: ConversationsRepository, + private val conversationMessageUiModelMapper: ConversationMessageUiModelMapper, + @param:DefaultDispatcher + private val defaultDispatcher: CoroutineDispatcher, + private val savedStateHandle: SavedStateHandle, +) : ViewModel() { + + private val _uiState = MutableStateFlow(ConversationUiState.Loading) + val uiState = _uiState.asStateFlow() + private var loadConversationJob: Job? = null + + var conversationId: String? = null + set(value) { + if (value != field) { + field = value + loadConversation() + } + } + + private fun loadConversation() { + val conversationId = conversationId ?: savedStateHandle[CONVERSATION_ID_KEY] + + if (conversationId == null) { + _uiState.update { ConversationUiState.Present() } + return + } + + savedStateHandle[CONVERSATION_ID_KEY] = conversationId + Log.d(LOG_TAG, "loadConversation: conversationId=$conversationId") + _uiState.update { ConversationUiState.Loading } + loadConversationJob?.cancel() + + loadConversationJob = viewModelScope.launch { + conversationsRepository + .getConversationMessages(conversationId = conversationId) + .map { messages -> + withContext(context = defaultDispatcher) { + messages.mapNotNull { message -> + conversationMessageUiModelMapper.map(data = message) + } + } + } + .collect { messages -> + Log.d(LOG_TAG, "Messages loaded: count=${messages.size}") + _uiState.update { + ConversationUiState.Present( + messages = messages, + ) + } + } + } + } + + private companion object { + private const val CONVERSATION_ID_KEY = "conversation_id" + private const val LOG_TAG = "ConversationViewModel" + } +} From bc5f780eefe74be1eb473d0dfd6ca60a81633ecf Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Wed, 25 Mar 2026 00:53:18 +0200 Subject: [PATCH 04/99] Add conversation metadata loading and improve conversation UI --- .../repository/ConversationMetadata.kt | 8 + .../repository/ConversationsRepository.kt | 64 ++- .../conversation/ConversationBindsModule.kt | 7 + .../conversation/v2/ConversationActivity.kt | 1 + .../ui/conversation/v2/ConversationScreen.kt | 39 +- .../conversation/v2/ConversationViewModel.kt | 118 +++-- .../v2/component/ConversationComposeBar.kt | 252 ++++++++++ .../v2/component/ConversationMessage.kt | 457 ++++++++++++++---- .../v2/component/ConversationMessages.kt | 318 ++++++++++-- .../v2/component/ConversationTopAppBar.kt | 245 ++++++++++ .../util/ConversationMessageDisplay.kt | 34 ++ .../ConversationMessageUiModelMapper.kt | 25 + .../ConversationMetadataUiStateMapper.kt | 21 + .../v2/model/ConversationMessageUiModel.kt | 1 + .../v2/model/ConversationMessagesUiState.kt | 15 + .../v2/model/ConversationMetadataUiState.kt | 18 + .../v2/model/ConversationUiState.kt | 21 +- 17 files changed, 1418 insertions(+), 226 deletions(-) create mode 100644 src/com/android/messaging/data/conversation/repository/ConversationMetadata.kt create mode 100644 src/com/android/messaging/ui/conversation/v2/component/ConversationComposeBar.kt create mode 100644 src/com/android/messaging/ui/conversation/v2/component/ConversationTopAppBar.kt create mode 100644 src/com/android/messaging/ui/conversation/v2/component/util/ConversationMessageDisplay.kt create mode 100644 src/com/android/messaging/ui/conversation/v2/mapper/ConversationMetadataUiStateMapper.kt create mode 100644 src/com/android/messaging/ui/conversation/v2/model/ConversationMessagesUiState.kt create mode 100644 src/com/android/messaging/ui/conversation/v2/model/ConversationMetadataUiState.kt diff --git a/src/com/android/messaging/data/conversation/repository/ConversationMetadata.kt b/src/com/android/messaging/data/conversation/repository/ConversationMetadata.kt new file mode 100644 index 00000000..1ed76830 --- /dev/null +++ b/src/com/android/messaging/data/conversation/repository/ConversationMetadata.kt @@ -0,0 +1,8 @@ +package com.android.messaging.data.conversation.repository + +internal data class ConversationMetadata( + val conversationName: String, + val selfParticipantId: String, + val isGroupConversation: Boolean, + val participantCount: Int, +) diff --git a/src/com/android/messaging/data/conversation/repository/ConversationsRepository.kt b/src/com/android/messaging/data/conversation/repository/ConversationsRepository.kt index fe86138c..8bce6914 100644 --- a/src/com/android/messaging/data/conversation/repository/ConversationsRepository.kt +++ b/src/com/android/messaging/data/conversation/repository/ConversationsRepository.kt @@ -3,8 +3,11 @@ package com.android.messaging.data.conversation.repository import android.content.ContentResolver import android.database.ContentObserver import android.net.Uri +import com.android.messaging.datamodel.DatabaseHelper.ConversationColumns import com.android.messaging.datamodel.MessagingContentProvider +import com.android.messaging.datamodel.data.ConversationListItemData import com.android.messaging.datamodel.data.ConversationMessageData +import com.android.messaging.di.core.DefaultDispatcher import com.android.messaging.di.core.IoDispatcher import com.android.messaging.util.db.ReversedCursor import kotlinx.coroutines.CoroutineDispatcher @@ -16,18 +19,43 @@ import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map import javax.inject.Inject -interface ConversationsRepository { +internal interface ConversationsRepository { + fun getConversationMetadata(conversationId: String): Flow fun getConversationMessages(conversationId: String): Flow> } internal class ConversationsRepositoryImpl @Inject constructor( private val contentResolver: ContentResolver, + @param:DefaultDispatcher + private val defaultDispatcher: CoroutineDispatcher, @param:IoDispatcher private val ioDispatcher: CoroutineDispatcher, ) : ConversationsRepository { + override fun getConversationMetadata(conversationId: String): Flow { + val uri = MessagingContentProvider.buildConversationMetadataUri(conversationId) + + return observeUri(uri = uri) + .flowOn(defaultDispatcher) + .map { + queryConversationMetadata(uri = uri) + } + .flowOn(ioDispatcher) + } + override fun getConversationMessages(conversationId: String): Flow> { val uri = MessagingContentProvider.buildConversationMessagesUri(conversationId) + + return observeUri(uri = uri) + .conflate() + .flowOn(defaultDispatcher) + .map { + queryConversationMessages(uri = uri) + } + .flowOn(ioDispatcher) + } + + private fun observeUri(uri: Uri): Flow { return callbackFlow { val observer = object : ContentObserver(null) { override fun onChange(selfChange: Boolean) { @@ -42,11 +70,37 @@ internal class ConversationsRepositoryImpl @Inject constructor( contentResolver.unregisterContentObserver(observer) } } - .conflate() - .map { - queryConversationMessages(uri = uri) + } + + private fun queryConversationMetadata(uri: Uri): ConversationMetadata? { + return contentResolver + .query( + uri, + ConversationListItemData.PROJECTION, + null, + null, + null, + ) + ?.use { cursor -> + if (!cursor.moveToFirst()) { + return@use null + } + + ConversationMetadata( + conversationName = cursor.getString( + cursor.getColumnIndexOrThrow(ConversationColumns.NAME), + ).orEmpty(), + selfParticipantId = cursor.getString( + cursor.getColumnIndexOrThrow(ConversationColumns.CURRENT_SELF_ID), + ).orEmpty(), + isGroupConversation = cursor.getInt( + cursor.getColumnIndexOrThrow(ConversationColumns.PARTICIPANT_COUNT), + ) > 1, + participantCount = cursor.getInt( + cursor.getColumnIndexOrThrow(ConversationColumns.PARTICIPANT_COUNT), + ), + ) } - .flowOn(ioDispatcher) } private fun queryConversationMessages(uri: Uri): List { diff --git a/src/com/android/messaging/di/conversation/ConversationBindsModule.kt b/src/com/android/messaging/di/conversation/ConversationBindsModule.kt index 7c9af8ea..fbc9823d 100644 --- a/src/com/android/messaging/di/conversation/ConversationBindsModule.kt +++ b/src/com/android/messaging/di/conversation/ConversationBindsModule.kt @@ -2,6 +2,8 @@ package com.android.messaging.di.conversation import com.android.messaging.data.conversation.repository.ConversationsRepository import com.android.messaging.data.conversation.repository.ConversationsRepositoryImpl +import com.android.messaging.ui.conversation.v2.mapper.ConversationMetadataUiStateMapper +import com.android.messaging.ui.conversation.v2.mapper.ConversationMetadataUiStateMapperImpl import com.android.messaging.ui.conversation.v2.mapper.ConversationMessageUiModelMapper import com.android.messaging.ui.conversation.v2.mapper.ConversationMessageUiModelMapperImpl import dagger.Binds @@ -24,4 +26,9 @@ internal abstract class ConversationBindsModule { abstract fun provideConversationMessageUiModelMapper( impl: ConversationMessageUiModelMapperImpl, ): ConversationMessageUiModelMapper + + @Binds + abstract fun provideConversationMetadataUiStateMapper( + impl: ConversationMetadataUiStateMapperImpl, + ): ConversationMetadataUiStateMapper } diff --git a/src/com/android/messaging/ui/conversation/v2/ConversationActivity.kt b/src/com/android/messaging/ui/conversation/v2/ConversationActivity.kt index ffa5a1c6..e7caa585 100644 --- a/src/com/android/messaging/ui/conversation/v2/ConversationActivity.kt +++ b/src/com/android/messaging/ui/conversation/v2/ConversationActivity.kt @@ -28,6 +28,7 @@ internal class ConversationActivity : ComponentActivity() { AppTheme { ConversationScreen( conversationId = conversationId, + onNavigateBack = ::finish, ) } } diff --git a/src/com/android/messaging/ui/conversation/v2/ConversationScreen.kt b/src/com/android/messaging/ui/conversation/v2/ConversationScreen.kt index 79fe3864..9d201c77 100644 --- a/src/com/android/messaging/ui/conversation/v2/ConversationScreen.kt +++ b/src/com/android/messaging/ui/conversation/v2/ConversationScreen.kt @@ -8,35 +8,52 @@ import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel +import com.android.messaging.ui.conversation.v2.component.ConversationComposeBar import com.android.messaging.ui.conversation.v2.component.ConversationMessages +import com.android.messaging.ui.conversation.v2.component.ConversationTopAppBar +import com.android.messaging.ui.conversation.v2.model.ConversationMessagesUiState import com.android.messaging.ui.conversation.v2.model.ConversationUiState @Composable internal fun ConversationScreen( modifier: Modifier = Modifier, conversationId: String? = null, + onNavigateBack: () -> Unit = {}, viewModel: ConversationViewModel = viewModel(), ) { LaunchedEffect(conversationId) { viewModel.conversationId = conversationId } - val uiState = viewModel.uiState.collectAsStateWithLifecycle() + val uiState by viewModel.uiState.collectAsStateWithLifecycle() Scaffold( - modifier = modifier - .fillMaxSize(), + modifier = modifier.fillMaxSize(), + topBar = { + ConversationTopAppBar( + metadata = uiState.metadata, + onNavigateBack = onNavigateBack, + ) + }, + bottomBar = { + ConversationComposeBar( + value = "", + enabled = false, + onValueChange = {}, + onSendClick = {}, + ) + }, ) { contentPadding -> ConversationScreenContent( - modifier = modifier - .padding(contentPadding), + modifier = Modifier.padding(contentPadding), conversationId = conversationId, - uiState = uiState.value, + uiState = uiState, ) } } @@ -47,8 +64,8 @@ private fun ConversationScreenContent( conversationId: String?, uiState: ConversationUiState, ) { - when (uiState) { - ConversationUiState.Loading -> { + when (val messagesState = uiState.messages) { + is ConversationMessagesUiState.Loading -> { Box( modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center, @@ -57,15 +74,15 @@ private fun ConversationScreenContent( } } - is ConversationUiState.Present -> { + is ConversationMessagesUiState.Present -> { val messagesListState = rememberMessagesListState( conversationId = conversationId, - initialMessageIndex = uiState.messages.lastIndex.coerceAtLeast(minimumValue = 0), + initialMessageIndex = messagesState.messages.lastIndex.coerceAtLeast(minimumValue = 0), ) ConversationMessages( modifier = modifier, - messages = uiState.messages, + messages = messagesState.messages, listState = messagesListState, ) } diff --git a/src/com/android/messaging/ui/conversation/v2/ConversationViewModel.kt b/src/com/android/messaging/ui/conversation/v2/ConversationViewModel.kt index 8bd81550..5304c93f 100644 --- a/src/com/android/messaging/ui/conversation/v2/ConversationViewModel.kt +++ b/src/com/android/messaging/ui/conversation/v2/ConversationViewModel.kt @@ -1,81 +1,111 @@ package com.android.messaging.ui.conversation.v2 -import android.util.Log import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.android.messaging.data.conversation.repository.ConversationsRepository import com.android.messaging.di.core.DefaultDispatcher import com.android.messaging.ui.conversation.v2.mapper.ConversationMessageUiModelMapper +import com.android.messaging.ui.conversation.v2.mapper.ConversationMetadataUiStateMapper +import com.android.messaging.ui.conversation.v2.model.ConversationMessagesUiState +import com.android.messaging.ui.conversation.v2.model.ConversationMetadataUiState import com.android.messaging.ui.conversation.v2.model.ConversationUiState import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.stateIn import javax.inject.Inject +@OptIn(ExperimentalCoroutinesApi::class) @HiltViewModel internal class ConversationViewModel @Inject constructor( private val conversationsRepository: ConversationsRepository, + private val conversationMetadataUiStateMapper: ConversationMetadataUiStateMapper, private val conversationMessageUiModelMapper: ConversationMessageUiModelMapper, @param:DefaultDispatcher private val defaultDispatcher: CoroutineDispatcher, private val savedStateHandle: SavedStateHandle, ) : ViewModel() { - private val _uiState = MutableStateFlow(ConversationUiState.Loading) - val uiState = _uiState.asStateFlow() - private var loadConversationJob: Job? = null + private val conversationIdFlow: StateFlow = savedStateHandle.getStateFlow( + key = CONVERSATION_ID_KEY, + initialValue = null, + ) - var conversationId: String? = null + val uiState: StateFlow = conversationIdFlow + .flatMapLatest { conversationId -> + observeConversationUiState(conversationId = conversationId) + } + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed( + stopTimeoutMillis = STATEFLOW_STOP_TIMEOUT_MILLIS, + ), + initialValue = ConversationUiState(), + ) + + var conversationId: String? + get() = conversationIdFlow.value set(value) { - if (value != field) { - field = value - loadConversation() + if (value != conversationIdFlow.value) { + savedStateHandle[CONVERSATION_ID_KEY] = value } - } - - private fun loadConversation() { - val conversationId = conversationId ?: savedStateHandle[CONVERSATION_ID_KEY] + } + private fun observeConversationUiState(conversationId: String?): Flow { if (conversationId == null) { - _uiState.update { ConversationUiState.Present() } - return + return flowOf(ConversationUiState()) } - savedStateHandle[CONVERSATION_ID_KEY] = conversationId - Log.d(LOG_TAG, "loadConversation: conversationId=$conversationId") - _uiState.update { ConversationUiState.Loading } - loadConversationJob?.cancel() - - loadConversationJob = viewModelScope.launch { - conversationsRepository - .getConversationMessages(conversationId = conversationId) - .map { messages -> - withContext(context = defaultDispatcher) { - messages.mapNotNull { message -> - conversationMessageUiModelMapper.map(data = message) - } - } - } - .collect { messages -> - Log.d(LOG_TAG, "Messages loaded: count=${messages.size}") - _uiState.update { - ConversationUiState.Present( - messages = messages, - ) - } - } + return combine( + observeConversationMetadataUiState(conversationId = conversationId), + observeConversationMessagesUiState(conversationId = conversationId), + ) { metadata, messages -> + ConversationUiState( + metadata = metadata, + messages = messages, + ) + }.onStart { + emit(ConversationUiState()) } } + private fun observeConversationMetadataUiState( + conversationId: String, + ): Flow { + return conversationsRepository + .getConversationMetadata(conversationId = conversationId) + .map { metadata -> + metadata + ?.let(conversationMetadataUiStateMapper::map) + ?: ConversationMetadataUiState.Present() + } + } + + private fun observeConversationMessagesUiState( + conversationId: String, + ): Flow { + return conversationsRepository + .getConversationMessages(conversationId = conversationId) + .map { messages -> + ConversationMessagesUiState.Present( + messages = messages.mapNotNull(conversationMessageUiModelMapper::map), + ) + } + .flowOn(defaultDispatcher) + } + private companion object { private const val CONVERSATION_ID_KEY = "conversation_id" - private const val LOG_TAG = "ConversationViewModel" + private const val STATEFLOW_STOP_TIMEOUT_MILLIS = 5_000L } } diff --git a/src/com/android/messaging/ui/conversation/v2/component/ConversationComposeBar.kt b/src/com/android/messaging/ui/conversation/v2/component/ConversationComposeBar.kt new file mode 100644 index 00000000..443bfddd --- /dev/null +++ b/src/com/android/messaging/ui/conversation/v2/component/ConversationComposeBar.kt @@ -0,0 +1,252 @@ +package com.android.messaging.ui.conversation.v2.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.Send +import androidx.compose.material.icons.rounded.AddCircleOutline +import androidx.compose.material.icons.rounded.Image +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldColors +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.android.messaging.R +import com.android.messaging.ui.core.AppTheme + +private val CONVERSATION_COMPOSE_BAR_FIELD_CORNER_RADIUS = 28.dp +private val CONVERSATION_COMPOSE_BAR_TEXT_FIELD_PADDING_HORIZONTAL = 12.dp +private val CONVERSATION_COMPOSE_BAR_TEXT_FIELD_PADDING_VERTICAL = 8.dp +private val CONVERSATION_COMPOSE_BAR_TRAILING_ACTION_SPACING = 2.dp +private val CONVERSATION_COMPOSE_BAR_PREVIEW_PADDING_VERTICAL = 24.dp + +@Composable +internal fun ConversationComposeBar( + modifier: Modifier = Modifier, + value: String, + enabled: Boolean, + onValueChange: (String) -> Unit, + onSendClick: () -> Unit, +) { + val presentation = rememberConversationComposeBarPresentation() + + ConversationComposeBarContainer( + modifier = modifier, + ) { + ConversationComposeTextField( + value = value, + enabled = enabled, + presentation = presentation, + onValueChange = onValueChange, + onSendClick = onSendClick, + ) + } +} + +@Composable +private fun rememberConversationComposeBarPresentation(): ConversationComposeBarPresentation { + val fieldShape = RoundedCornerShape(size = CONVERSATION_COMPOSE_BAR_FIELD_CORNER_RADIUS) + val fieldColors = conversationComposeBarTextFieldColors() + + return remember( + fieldShape, + fieldColors, + ) { + ConversationComposeBarPresentation( + fieldShape = fieldShape, + fieldColors = fieldColors, + ) + } +} + +@Composable +private fun conversationComposeBarTextFieldColors(): TextFieldColors { + return TextFieldDefaults.colors( + focusedContainerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + unfocusedContainerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + disabledContainerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + disabledIndicatorColor = Color.Transparent, + focusedTextColor = MaterialTheme.colorScheme.onSurface, + unfocusedTextColor = MaterialTheme.colorScheme.onSurface, + disabledTextColor = MaterialTheme.colorScheme.onSurface, + focusedPlaceholderColor = MaterialTheme.colorScheme.onSurfaceVariant, + unfocusedPlaceholderColor = MaterialTheme.colorScheme.onSurfaceVariant, + disabledPlaceholderColor = MaterialTheme.colorScheme.onSurfaceVariant, + focusedLeadingIconColor = MaterialTheme.colorScheme.onSurfaceVariant, + unfocusedLeadingIconColor = MaterialTheme.colorScheme.onSurfaceVariant, + disabledLeadingIconColor = MaterialTheme.colorScheme.onSurfaceVariant, + focusedTrailingIconColor = MaterialTheme.colorScheme.onSurfaceVariant, + unfocusedTrailingIconColor = MaterialTheme.colorScheme.onSurfaceVariant, + disabledTrailingIconColor = MaterialTheme.colorScheme.onSurfaceVariant, + ) +} + +@Composable +private fun ConversationComposeBarContainer( + modifier: Modifier = Modifier, + content: @Composable () -> Unit, +) { + Row( + modifier = modifier + .fillMaxWidth() + .imePadding() + .navigationBarsPadding(), + horizontalArrangement = Arrangement.Center, + ) { + content() + } +} + +@Composable +private fun ConversationComposeTextField( + value: String, + enabled: Boolean, + presentation: ConversationComposeBarPresentation, + onValueChange: (String) -> Unit, + onSendClick: () -> Unit, +) { + TextField( + modifier = Modifier + .fillMaxWidth() + .padding( + horizontal = CONVERSATION_COMPOSE_BAR_TEXT_FIELD_PADDING_HORIZONTAL, + vertical = CONVERSATION_COMPOSE_BAR_TEXT_FIELD_PADDING_VERTICAL, + ), + value = value, + onValueChange = onValueChange, + enabled = enabled, + shape = presentation.fieldShape, + colors = presentation.fieldColors, + placeholder = { + ConversationComposePlaceholder() + }, + leadingIcon = { + ConversationComposeLeadingAction( + enabled = enabled, + onClick = onSendClick, + ) + }, + trailingIcon = { + ConversationComposeTrailingActions( + enabled = enabled, + onSendClick = onSendClick, + ) + }, + maxLines = 4, + ) +} + +@Composable +private fun ConversationComposePlaceholder() { + Text( + text = stringResource(id = R.string.compose_message_view_hint_text), + style = MaterialTheme.typography.bodyLarge, + ) +} + +@Composable +private fun ConversationComposeLeadingAction( + enabled: Boolean, + onClick: () -> Unit, +) { + IconButton( + onClick = onClick, + enabled = enabled, + ) { + Icon( + imageVector = Icons.Rounded.AddCircleOutline, + contentDescription = null, + ) + } +} + +@Composable +private fun ConversationComposeTrailingActions( + enabled: Boolean, + onSendClick: () -> Unit, +) { + Row( + horizontalArrangement = Arrangement.spacedBy(space = CONVERSATION_COMPOSE_BAR_TRAILING_ACTION_SPACING), + verticalAlignment = Alignment.CenterVertically, + ) { + ConversationComposeImageAction( + enabled = enabled, + onClick = onSendClick, + ) + + ConversationComposeSendAction( + enabled = enabled, + onClick = onSendClick, + ) + } +} + +@Composable +private fun ConversationComposeImageAction( + enabled: Boolean, + onClick: () -> Unit, +) { + IconButton( + onClick = onClick, + enabled = enabled, + ) { + Icon( + imageVector = Icons.Rounded.Image, + contentDescription = null, + ) + } +} + +@Composable +private fun ConversationComposeSendAction( + enabled: Boolean, + onClick: () -> Unit, +) { + IconButton( + onClick = onClick, + enabled = enabled, + ) { + Icon( + imageVector = Icons.AutoMirrored.Rounded.Send, + contentDescription = stringResource(id = R.string.sendButtonContentDescription), + ) + } +} + +private data class ConversationComposeBarPresentation( + val fieldShape: RoundedCornerShape, + val fieldColors: TextFieldColors, +) + +@Composable +private fun ConversationComposeBarPreviewContainer( + content: @Composable () -> Unit, +) { + AppTheme { + Box( + modifier = Modifier + .background(color = MaterialTheme.colorScheme.background) + .padding(vertical = CONVERSATION_COMPOSE_BAR_PREVIEW_PADDING_VERTICAL), + ) { + content() + } + } +} diff --git a/src/com/android/messaging/ui/conversation/v2/component/ConversationMessage.kt b/src/com/android/messaging/ui/conversation/v2/component/ConversationMessage.kt index 6a4b3602..3d74ed6b 100644 --- a/src/com/android/messaging/ui/conversation/v2/component/ConversationMessage.kt +++ b/src/com/android/messaging/ui/conversation/v2/component/ConversationMessage.kt @@ -1,155 +1,406 @@ package com.android.messaging.ui.conversation.v2.component +import android.content.Context +import android.text.format.DateUtils import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import com.android.messaging.ui.conversation.v2.model.ConversationMessagePartUiModel +import com.android.messaging.R import com.android.messaging.ui.conversation.v2.model.ConversationMessageUiModel +private const val MESSAGE_BUBBLE_MAX_WIDTH_DP = 360 +private const val MESSAGE_BUBBLE_WIDTH_FRACTION = 0.8f +private const val MESSAGE_BUBBLE_CORNER_RADIUS_DP = 24 +private const val MESSAGE_BUBBLE_CONNECTED_CORNER_RADIUS_DP = 6 + @Composable internal fun ConversationMessage( modifier: Modifier = Modifier, message: ConversationMessageUiModel, ) { - val horizontalArrangement = if (message.isIncoming) { - Arrangement.Start - } else { - Arrangement.End + BoxWithConstraints( + modifier = modifier.fillMaxWidth(), + ) { + val maxBubbleWidth = remember(maxWidth) { + (maxWidth * MESSAGE_BUBBLE_WIDTH_FRACTION) + .coerceAtMost(MESSAGE_BUBBLE_MAX_WIDTH_DP.dp) + } + val presentation = rememberConversationMessagePresentation(message = message) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = messageHorizontalArrangement(message = message), + ) { + ConversationMessageContent( + message = message, + presentation = presentation, + maxBubbleWidth = maxBubbleWidth, + ) + } } - val bubbleColor = if (message.isIncoming) { - MaterialTheme.colorScheme.surfaceVariant - } else { - MaterialTheme.colorScheme.primaryContainer +} + +@Immutable +private data class ConversationMessagePresentation( + val bubbleShape: RoundedCornerShape, + val bodyText: String, + val metadataText: String?, + val showSender: Boolean, +) + +@Composable +private fun rememberConversationMessagePresentation( + message: ConversationMessageUiModel, +): ConversationMessagePresentation { + val context = LocalContext.current + val configuration = LocalConfiguration.current + + val bubbleShape = remember( + message.canClusterWithPrevious, + message.canClusterWithNext, + ) { + messageBubbleShape(message = message) } - val bubbleShape = messageBubbleShape(message = message) - val messageBody = buildMessageBody(message = message) - Row( - modifier = modifier.fillMaxWidth(), - horizontalArrangement = horizontalArrangement, + val bodyText = remember( + message.text, + message.mmsSubject, + message.parts, + ) { + buildMessageBody(message = message) + } + + val statusTextResourceId = remember(message.status) { + messageStatusTextResourceId(status = message.status) + } + val statusText = statusTextResourceId?.let { stringResource(id = it) } + + val metadataText = remember( + context, + configuration, + message.canClusterWithNext, + message.displayTimestamp, + statusText, + ) { + buildMessageMetadataText( + context = context, + canClusterWithNext = message.canClusterWithNext, + timestamp = message.displayTimestamp, + statusText = statusText, + ) + } + + val showSender = remember( + message.isIncoming, + message.senderDisplayName, + message.canClusterWithPrevious, + ) { + message.isIncoming && + !message.senderDisplayName.isNullOrBlank() && + !message.canClusterWithPrevious + } + + return remember( + bubbleShape, + bodyText, + metadataText, + showSender, + ) { + ConversationMessagePresentation( + bubbleShape = bubbleShape, + bodyText = bodyText, + metadataText = metadataText, + showSender = showSender, + ) + } +} + +private fun messageHorizontalArrangement(message: ConversationMessageUiModel): Arrangement.Horizontal { + return when { + message.isIncoming -> Arrangement.Start + else -> Arrangement.End + } +} + +@Composable +private fun ConversationMessageContent( + message: ConversationMessageUiModel, + presentation: ConversationMessagePresentation, + maxBubbleWidth: Dp, +) { + Column( + modifier = Modifier.widthIn(max = maxBubbleWidth), + horizontalAlignment = messageContentHorizontalAlignment(message = message), + ) { + ConversationMessageBubble( + message = message, + presentation = presentation, + maxBubbleWidth = maxBubbleWidth, + ) + + ConversationMessageMetadata( + message = message, + metadataText = presentation.metadataText, + ) + } +} + +@Composable +private fun ConversationMessageBubble( + message: ConversationMessageUiModel, + presentation: ConversationMessagePresentation, + maxBubbleWidth: Dp, +) { + Surface( + color = messageBubbleColor(message = message), + contentColor = messageBubbleContentColor(message = message), + shape = presentation.bubbleShape, + modifier = Modifier.widthIn(max = maxBubbleWidth), ) { - Surface( - color = bubbleColor, - shape = bubbleShape, - modifier = Modifier.fillMaxWidth(fraction = 0.8f), + Column( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(space = 4.dp), ) { - Column( - modifier = Modifier.padding(all = 12.dp), - verticalArrangement = Arrangement.spacedBy(space = 4.dp), - ) { - if (message.isIncoming && !message.senderDisplayName.isNullOrBlank()) { - Text( - text = message.senderDisplayName, - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.primary, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - - Text( - text = messageBody, - style = MaterialTheme.typography.bodyLarge, - ) - } + ConversationMessageSender( + senderDisplayName = message.senderDisplayName, + showSender = presentation.showSender, + ) + + Text( + text = presentation.bodyText, + style = MaterialTheme.typography.bodyLarge, + ) } } } -private fun messageBubbleShape(message: ConversationMessageUiModel): RoundedCornerShape { - val cornerRadius = 20.dp - val topCornerRadius = if (message.canClusterWithPrevious) { - 0.dp - } else { - cornerRadius +@Composable +private fun ConversationMessageSender( + senderDisplayName: String?, + showSender: Boolean, +) { + if (!showSender || senderDisplayName == null) { + return } - val bottomCornerRadius = if (message.canClusterWithNext) { - 0.dp - } else { - cornerRadius + + Text( + text = senderDisplayName, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) +} + +@Composable +private fun ConversationMessageMetadata( + message: ConversationMessageUiModel, + metadataText: String?, +) { + if (metadataText == null) { + return } - return RoundedCornerShape( - topStart = topCornerRadius, - topEnd = topCornerRadius, - bottomStart = bottomCornerRadius, - bottomEnd = bottomCornerRadius, + Text( + text = metadataText, + style = MaterialTheme.typography.labelSmall, + color = messageMetadataColor(message = message), + textAlign = messageMetadataTextAlign(message = message), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 6.dp), ) } -private fun buildMessageBody(message: ConversationMessageUiModel): String { - val text = message.text?.takeIf { value -> - value.isNotBlank() +private fun messageContentHorizontalAlignment( + message: ConversationMessageUiModel, +): Alignment.Horizontal { + return when { + message.isIncoming -> Alignment.Start + else -> Alignment.End } - if (text != null) { - return text +} + +private fun messageMetadataTextAlign(message: ConversationMessageUiModel): TextAlign { + return when { + message.isIncoming -> TextAlign.Start + else -> TextAlign.End } +} - val subject = message.mmsSubject?.takeIf { value -> - value.isNotBlank() +@Composable +private fun messageBubbleColor(message: ConversationMessageUiModel): Color { + return when { + message.isIncoming -> MaterialTheme.colorScheme.surfaceContainerHigh + else -> MaterialTheme.colorScheme.primaryContainer } - if (subject != null) { - return subject +} + +@Composable +private fun messageBubbleContentColor(message: ConversationMessageUiModel): Color { + return when { + message.isIncoming -> MaterialTheme.colorScheme.onSurface + else -> MaterialTheme.colorScheme.onPrimaryContainer } +} - val partText = message.parts.firstNotNullOfOrNull { part -> - part.text?.takeIf { value -> - value.isNotBlank() - } +private fun messageBubbleShape(message: ConversationMessageUiModel): RoundedCornerShape { + val cornerRadius = MESSAGE_BUBBLE_CORNER_RADIUS_DP.dp + + val topStartCornerRadius = clusteredCornerRadius( + clustersWithAdjacent = message.canClusterWithPrevious, + ) + val topEndCornerRadius = clusteredCornerRadius( + clustersWithAdjacent = message.canClusterWithPrevious, + useFreeSide = true, + defaultRadius = cornerRadius, + ) + val bottomStartCornerRadius = clusteredCornerRadius( + clustersWithAdjacent = message.canClusterWithNext, + ) + val bottomEndCornerRadius = clusteredCornerRadius( + clustersWithAdjacent = message.canClusterWithNext, + useFreeSide = true, + defaultRadius = cornerRadius, + ) + + return RoundedCornerShape( + topStart = if (message.isIncoming) topStartCornerRadius else topEndCornerRadius, + topEnd = if (message.isIncoming) topEndCornerRadius else topStartCornerRadius, + bottomStart = if (message.isIncoming) bottomStartCornerRadius else bottomEndCornerRadius, + bottomEnd = if (message.isIncoming) bottomEndCornerRadius else bottomStartCornerRadius, + ) +} + +private fun clusteredCornerRadius( + clustersWithAdjacent: Boolean, + useFreeSide: Boolean = false, + defaultRadius: Dp = MESSAGE_BUBBLE_CORNER_RADIUS_DP.dp, +): Dp { + if (!clustersWithAdjacent) { + return defaultRadius } - if (partText != null) { - return partText + + if (useFreeSide) { + return defaultRadius } - return message.parts.firstOrNull()?.contentType.orEmpty() + return MESSAGE_BUBBLE_CONNECTED_CORNER_RADIUS_DP.dp } +private fun buildMessageBody(message: ConversationMessageUiModel): String { + message + .text + ?.takeIf { it.isNotBlank() } + ?.let { return it } + + message + .mmsSubject + ?.takeIf { it.isNotBlank() } + ?.let { return it } -private fun previewMessage( - messageId: String, - text: String, - isIncoming: Boolean, - senderDisplayName: String?, - canClusterWithPrevious: Boolean, + message + .parts + .firstNotNullOfOrNull { part -> + part.text?.takeIf { it.isNotBlank() } + } + ?.let { return it } + + return message.parts.firstOrNull()?.contentType.orEmpty() +} + +private fun buildMessageMetadataText( + context: Context, canClusterWithNext: Boolean, -): ConversationMessageUiModel { - return ConversationMessageUiModel( - messageId = messageId, - conversationId = "preview-conversation", - text = text, - parts = listOf( - ConversationMessagePartUiModel( - contentType = "text/plain", - text = text, - contentUri = null, - width = 0, - height = 0, - ), - ), - sentTimestamp = 0L, - receivedTimestamp = 0L, - status = if (isIncoming) { - ConversationMessageUiModel.Status.Incoming.Complete - } else { - ConversationMessageUiModel.Status.Outgoing.Complete - }, - isIncoming = isIncoming, - senderDisplayName = senderDisplayName, - senderAvatarUri = null, - senderContactLookupKey = null, - canClusterWithPrevious = canClusterWithPrevious, - canClusterWithNext = canClusterWithNext, - mmsSubject = null, - protocol = ConversationMessageUiModel.Protocol.SMS, + timestamp: Long, + statusText: String?, +): String? { + if (canClusterWithNext) { + return null + } + + if (timestamp <= 0L) { + return statusText + } + + val formattedTime = DateUtils.formatDateTime( + context, + timestamp, + DateUtils.FORMAT_SHOW_TIME, ) + + if (statusText == null) { + return formattedTime + } + + return "$formattedTime \u2022 $statusText" +} + +private fun messageStatusTextResourceId(status: ConversationMessageUiModel.Status): Int? { + return when (status) { + ConversationMessageUiModel.Status.Unknown -> null + ConversationMessageUiModel.Status.Outgoing.Complete -> null + ConversationMessageUiModel.Status.Outgoing.Delivered -> R.string.delivered_status_content_description + + ConversationMessageUiModel.Status.Outgoing.Draft -> null + ConversationMessageUiModel.Status.Outgoing.YetToSend -> null + ConversationMessageUiModel.Status.Outgoing.Sending -> R.string.message_status_sending + + ConversationMessageUiModel.Status.Outgoing.Resending -> R.string.message_status_send_retrying + + ConversationMessageUiModel.Status.Outgoing.AwaitingRetry -> R.string.message_status_failed + + ConversationMessageUiModel.Status.Outgoing.Failed -> R.string.message_status_failed + + ConversationMessageUiModel.Status.Outgoing.FailedEmergencyNumber -> R.string.message_status_failed + + ConversationMessageUiModel.Status.Incoming.Complete -> null + ConversationMessageUiModel.Status.Incoming.YetToManualDownload -> R.string.message_status_download + + ConversationMessageUiModel.Status.Incoming.RetryingManualDownload -> R.string.message_status_downloading + + ConversationMessageUiModel.Status.Incoming.ManualDownloading -> R.string.message_status_downloading + + ConversationMessageUiModel.Status.Incoming.RetryingAutoDownload -> R.string.message_status_downloading + + ConversationMessageUiModel.Status.Incoming.AutoDownloading -> R.string.message_status_downloading + + ConversationMessageUiModel.Status.Incoming.DownloadFailed -> R.string.message_status_download_failed + + ConversationMessageUiModel.Status.Incoming.ExpiredOrNotAvailable -> R.string.message_status_download_error + } +} + +@Composable +private fun messageMetadataColor(message: ConversationMessageUiModel): Color { + return when (message.status) { + ConversationMessageUiModel.Status.Outgoing.AwaitingRetry, + ConversationMessageUiModel.Status.Outgoing.Failed, + ConversationMessageUiModel.Status.Outgoing.FailedEmergencyNumber, + ConversationMessageUiModel.Status.Incoming.DownloadFailed, + ConversationMessageUiModel.Status.Incoming.ExpiredOrNotAvailable -> MaterialTheme.colorScheme.error + + else -> MaterialTheme.colorScheme.onSurfaceVariant + } } diff --git a/src/com/android/messaging/ui/conversation/v2/component/ConversationMessages.kt b/src/com/android/messaging/ui/conversation/v2/component/ConversationMessages.kt index 48d5d7b2..7fe40f04 100644 --- a/src/com/android/messaging/ui/conversation/v2/component/ConversationMessages.kt +++ b/src/com/android/messaging/ui/conversation/v2/component/ConversationMessages.kt @@ -1,19 +1,57 @@ package com.android.messaging.ui.conversation.v2.component +import android.content.Context +import android.text.format.DateUtils import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.runtime.Composable import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import com.android.messaging.ui.conversation.v2.model.ConversationMessagePartUiModel +import com.android.messaging.ui.conversation.v2.component.util.conversationMessageDisplayEpochDay +import com.android.messaging.ui.conversation.v2.component.util.conversationMessageDisplayLocalDate import com.android.messaging.ui.conversation.v2.model.ConversationMessageUiModel +import java.time.LocalDate +import java.util.TimeZone + +private const val COMMON_DATE_SEPARATOR_FORMAT_FLAGS = DateUtils.FORMAT_SHOW_WEEKDAY or + DateUtils.FORMAT_SHOW_DATE or + DateUtils.FORMAT_ABBREV_MONTH + +private val CONVERSATION_MESSAGES_CONTENT_PADDING = PaddingValues( + start = 16.dp, + top = 24.dp, + end = 16.dp, + bottom = 24.dp, +) + +private val CONVERSATION_MESSAGES_CLUSTER_TOP_PADDING = 2.dp +private val CONVERSATION_MESSAGES_GROUP_TOP_PADDING = 12.dp +private val CONVERSATION_MESSAGES_SEPARATOR_SPACING = 12.dp +private val CONVERSATION_MESSAGES_SEPARATOR_PADDING = PaddingValues( + horizontal = 14.dp, + vertical = 6.dp, +) + +private enum class ConversationMessagesItemContentType { + Message, + MessageWithDateSeparator, +} @Composable internal fun ConversationMessages( @@ -21,76 +59,262 @@ internal fun ConversationMessages( messages: List, listState: LazyListState, ) { + val configuration = LocalConfiguration.current + val timeZone = remember(configuration) { + TimeZone.getDefault() + } + LazyColumn( state = listState, modifier = modifier .fillMaxSize() .background(color = MaterialTheme.colorScheme.background), - contentPadding = PaddingValues(all = 16.dp), + contentPadding = CONVERSATION_MESSAGES_CONTENT_PADDING, ) { itemsIndexed( items = messages, key = { _, message -> message.messageId }, + contentType = { index, _ -> + conversationMessagesItemContentType( + messages = messages, + index = index, + timeZone = timeZone, + ) + }, ) { index, message -> - ConversationMessage( - modifier = Modifier.padding(top = messageItemTopPadding(index = index, message = message)), + ConversationMessagesItem( + index = index, + message = message, + previousMessage = previousMessage( + messages = messages, + index = index, + ), + ) + } + } +} + +@Immutable +private data class ConversationMessagesItemPresentation( + val showDateSeparator: Boolean, + val dateSeparatorText: String?, + val topPadding: Dp, +) + +private fun conversationMessagesItemContentType( + messages: List, + index: Int, + timeZone: TimeZone, +): ConversationMessagesItemContentType { + val shouldShowDateSeparator = shouldShowDateSeparator( + currentMessage = messages[index], + previousMessage = previousMessage( + messages = messages, + index = index, + ), + timeZone = timeZone, + ) + + return when { + shouldShowDateSeparator -> ConversationMessagesItemContentType.MessageWithDateSeparator + else -> ConversationMessagesItemContentType.Message + } +} + +private fun previousMessage( + messages: List, + index: Int, +): ConversationMessageUiModel? { + return when { + index > 0 -> messages[index - 1] + else -> null + } +} + +@Composable +private fun ConversationMessagesItem( + index: Int, + message: ConversationMessageUiModel, + previousMessage: ConversationMessageUiModel?, +) { + val presentation = rememberConversationMessagesItemPresentation( + index = index, + message = message, + previousMessage = previousMessage, + ) + + ColumnWithSeparator( + showDateSeparator = presentation.showDateSeparator, + dateSeparatorText = presentation.dateSeparatorText, + ) { + ConversationMessage( + modifier = Modifier.padding(top = presentation.topPadding), + message = message, + ) + } +} + +@Composable +private fun rememberConversationMessagesItemPresentation( + index: Int, + message: ConversationMessageUiModel, + previousMessage: ConversationMessageUiModel?, +): ConversationMessagesItemPresentation { + val context = LocalContext.current + val configuration = LocalConfiguration.current + val timeZone = remember(configuration) { + TimeZone.getDefault() + } + + val showDateSeparator = remember( + timeZone, + message.displayTimestamp, + previousMessage?.displayTimestamp, + ) { + shouldShowDateSeparator( + currentMessage = message, + previousMessage = previousMessage, + timeZone = timeZone, + ) + } + + val dateSeparatorText = remember( + context, + configuration, + showDateSeparator, + message.displayTimestamp, + ) { + if (!showDateSeparator) { + null + } else { + formatDateSeparatorText( + context = context, message = message, ) } } + + val topPadding = remember( + index, + showDateSeparator, + message.canClusterWithPrevious, + ) { + messageItemTopPadding( + index = index, + message = message, + showDateSeparator = showDateSeparator, + ) + } + + return remember( + showDateSeparator, + dateSeparatorText, + topPadding, + ) { + ConversationMessagesItemPresentation( + showDateSeparator = showDateSeparator, + dateSeparatorText = dateSeparatorText, + topPadding = topPadding, + ) + } } private fun messageItemTopPadding( index: Int, message: ConversationMessageUiModel, + showDateSeparator: Boolean, ): Dp { - if (index == 0) { - return 0.dp + return when { + index == 0 || showDateSeparator -> 0.dp + message.canClusterWithPrevious -> CONVERSATION_MESSAGES_CLUSTER_TOP_PADDING + else -> CONVERSATION_MESSAGES_GROUP_TOP_PADDING } +} - return if (message.canClusterWithPrevious) { - 2.dp - } else { - 8.dp +@Composable +private fun ColumnWithSeparator( + showDateSeparator: Boolean, + dateSeparatorText: String?, + content: @Composable () -> Unit, +) { + val verticalSpace = when { + showDateSeparator -> CONVERSATION_MESSAGES_SEPARATOR_SPACING + else -> 0.dp } -} + Column( + verticalArrangement = Arrangement.spacedBy(space = verticalSpace), + ) { + if (showDateSeparator && dateSeparatorText != null) { + ConversationDateSeparator( + text = dateSeparatorText, + ) + } + + content() + } +} -private fun previewMessage( - messageId: String, +@Composable +private fun ConversationDateSeparator( text: String, - isIncoming: Boolean, - senderDisplayName: String?, - canClusterWithPrevious: Boolean, - canClusterWithNext: Boolean, -): ConversationMessageUiModel { - return ConversationMessageUiModel( - messageId = messageId, - conversationId = "preview-conversation", - text = text, - parts = listOf( - ConversationMessagePartUiModel( - contentType = "text/plain", - text = text, - contentUri = null, - width = 0, - height = 0, - ), - ), - sentTimestamp = 0L, - receivedTimestamp = 0L, - status = if (isIncoming) { - ConversationMessageUiModel.Status.Incoming.Complete - } else { - ConversationMessageUiModel.Status.Outgoing.Complete - }, - isIncoming = isIncoming, - senderDisplayName = senderDisplayName, - senderAvatarUri = null, - senderContactLookupKey = null, - canClusterWithPrevious = canClusterWithPrevious, - canClusterWithNext = canClusterWithNext, - mmsSubject = null, - protocol = ConversationMessageUiModel.Protocol.SMS, +) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + ) { + Text( + text = text, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(CONVERSATION_MESSAGES_SEPARATOR_PADDING), + ) + } +} + +private fun shouldShowDateSeparator( + currentMessage: ConversationMessageUiModel, + previousMessage: ConversationMessageUiModel?, + timeZone: TimeZone, +): Boolean { + if (previousMessage == null) { + return true + } + + val currentEpochDay = conversationMessageDisplayEpochDay( + displayTimestamp = currentMessage.displayTimestamp, + timeZone = timeZone, + ) ?: return false + val previousEpochDay = conversationMessageDisplayEpochDay( + displayTimestamp = previousMessage.displayTimestamp, + timeZone = timeZone, + ) + + return previousEpochDay != currentEpochDay +} + +private fun formatDateSeparatorText( + context: Context, + message: ConversationMessageUiModel, +): String? { + val timestamp = message.displayTimestamp + + if (timestamp <= 0L) { + return null + } + + val isSameYear = conversationMessageDisplayLocalDate( + displayTimestamp = timestamp, + )?.year == LocalDate.now().year + + val dateTimeFormatFlags = when { + isSameYear -> COMMON_DATE_SEPARATOR_FORMAT_FLAGS or DateUtils.FORMAT_NO_YEAR + else -> COMMON_DATE_SEPARATOR_FORMAT_FLAGS or DateUtils.FORMAT_SHOW_YEAR + } + + return DateUtils.formatDateTime( + context, + timestamp, + dateTimeFormatFlags, ) } diff --git a/src/com/android/messaging/ui/conversation/v2/component/ConversationTopAppBar.kt b/src/com/android/messaging/ui/conversation/v2/component/ConversationTopAppBar.kt new file mode 100644 index 00000000..f1a2f72b --- /dev/null +++ b/src/com/android/messaging/ui/conversation/v2/component/ConversationTopAppBar.kt @@ -0,0 +1,245 @@ +package com.android.messaging.ui.conversation.v2.component + +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.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.ArrowBack +import androidx.compose.material.icons.rounded.Group +import androidx.compose.material.icons.rounded.Person +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarColors +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.android.messaging.R +import com.android.messaging.ui.conversation.v2.model.ConversationMetadataUiState + +private val CONVERSATION_TOP_APP_BAR_TITLE_SPACING = 12.dp +private val CONVERSATION_TOP_APP_BAR_AVATAR_SIZE = 36.dp +private val CONVERSATION_TOP_APP_BAR_AVATAR_ICON_SIZE = 20.dp + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun ConversationTopAppBar( + modifier: Modifier = Modifier, + metadata: ConversationMetadataUiState, + onNavigateBack: () -> Unit, +) { + val presentation = rememberConversationTopAppBarPresentation( + metadata = metadata, + ) + + TopAppBar( + modifier = modifier.fillMaxWidth(), + colors = conversationTopAppBarColors(), + title = { + ConversationTopAppBarTitle( + presentation = presentation, + ) + }, + navigationIcon = { + ConversationTopAppBarNavigationIcon( + onNavigateBack = onNavigateBack, + ) + }, + ) +} + +@Composable +private fun conversationTopAppBarColors(): TopAppBarColors { + return TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + navigationIconContentColor = MaterialTheme.colorScheme.onSurface, + titleContentColor = MaterialTheme.colorScheme.onSurface, + actionIconContentColor = MaterialTheme.colorScheme.onSurfaceVariant, + ) +} + +@Composable +private fun rememberConversationTopAppBarPresentation( + metadata: ConversationMetadataUiState, +): ConversationTopAppBarPresentation { + val title = conversationTitle( + metadata = metadata, + ) + val subtitle = conversationSubtitle( + metadata = metadata, + ) + val isGroupConversation = conversationIsGroup( + metadata = metadata, + ) + + return remember( + metadata, + title, + subtitle, + isGroupConversation, + ) { + ConversationTopAppBarPresentation( + title = title, + subtitle = subtitle, + isGroupConversation = isGroupConversation, + ) + } +} + +@Composable +private fun ConversationTopAppBarTitle( + presentation: ConversationTopAppBarPresentation, +) { + Row( + horizontalArrangement = Arrangement.spacedBy(space = CONVERSATION_TOP_APP_BAR_TITLE_SPACING), + verticalAlignment = Alignment.CenterVertically, + ) { + ConversationAvatar( + isGroupConversation = presentation.isGroupConversation, + ) + + ConversationTopAppBarText( + presentation = presentation, + ) + } +} + +@Composable +private fun ConversationTopAppBarText( + presentation: ConversationTopAppBarPresentation, +) { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.Center, + ) { + Text( + text = presentation.title, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + + if (presentation.subtitle != null) { + Text( + text = presentation.subtitle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } +} + +@Composable +private fun ConversationTopAppBarNavigationIcon( + onNavigateBack: () -> Unit, +) { + IconButton( + onClick = onNavigateBack, + ) { + Icon( + imageVector = Icons.AutoMirrored.Rounded.ArrowBack, + contentDescription = stringResource(id = R.string.back), + ) + } +} + +@Composable +private fun ConversationAvatar( + isGroupConversation: Boolean, +) { + Surface( + color = MaterialTheme.colorScheme.secondaryContainer, + contentColor = MaterialTheme.colorScheme.onSecondaryContainer, + shape = CircleShape, + modifier = Modifier.size(size = CONVERSATION_TOP_APP_BAR_AVATAR_SIZE), + ) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = when { + isGroupConversation -> Icons.Rounded.Group + else -> Icons.Rounded.Person + }, + contentDescription = null, + modifier = Modifier.size(size = CONVERSATION_TOP_APP_BAR_AVATAR_ICON_SIZE), + ) + } + } +} + +@Composable +private fun conversationTitle( + metadata: ConversationMetadataUiState, +): String { + return when (metadata) { + ConversationMetadataUiState.Loading -> stringResource(id = R.string.app_name) + + is ConversationMetadataUiState.Present -> { + metadata + .title + .takeIf { it.isNotBlank() } + ?: stringResource(id = R.string.app_name) + } + } +} + +private fun conversationIsGroup( + metadata: ConversationMetadataUiState, +): Boolean { + return when (metadata) { + ConversationMetadataUiState.Loading -> false + is ConversationMetadataUiState.Present -> metadata.isGroupConversation + } +} + +@Composable +private fun conversationSubtitle( + metadata: ConversationMetadataUiState, +): String? { + return when (metadata) { + ConversationMetadataUiState.Loading -> stringResource(id = R.string.loading_messages) + + is ConversationMetadataUiState.Present -> { + when { + metadata.isGroupConversation && metadata.participantCount > 1 -> { + pluralStringResource( + id = R.plurals.wearable_participant_count, + count = metadata.participantCount, + metadata.participantCount, + ) + } + + else -> null + } + } + } +} + +@Immutable +private data class ConversationTopAppBarPresentation( + val title: String, + val subtitle: String?, + val isGroupConversation: Boolean, +) diff --git a/src/com/android/messaging/ui/conversation/v2/component/util/ConversationMessageDisplay.kt b/src/com/android/messaging/ui/conversation/v2/component/util/ConversationMessageDisplay.kt new file mode 100644 index 00000000..61639e28 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/v2/component/util/ConversationMessageDisplay.kt @@ -0,0 +1,34 @@ +package com.android.messaging.ui.conversation.v2.component.util + +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneId +import java.util.TimeZone + +private const val MILLIS_PER_DAY = 86_400_000L + +internal fun conversationMessageDisplayEpochDay( + displayTimestamp: Long, + timeZone: TimeZone, +): Long? { + if (displayTimestamp <= 0L) { + return null + } + + val localTimestamp = displayTimestamp + timeZone.getOffset(displayTimestamp) + + return Math.floorDiv(localTimestamp, MILLIS_PER_DAY) +} + +internal fun conversationMessageDisplayLocalDate( + displayTimestamp: Long, +): LocalDate? { + if (displayTimestamp <= 0L) { + return null + } + + return Instant + .ofEpochMilli(displayTimestamp) + .atZone(ZoneId.systemDefault()) + .toLocalDate() +} diff --git a/src/com/android/messaging/ui/conversation/v2/mapper/ConversationMessageUiModelMapper.kt b/src/com/android/messaging/ui/conversation/v2/mapper/ConversationMessageUiModelMapper.kt index 91bb8294..43d98a0e 100644 --- a/src/com/android/messaging/ui/conversation/v2/mapper/ConversationMessageUiModelMapper.kt +++ b/src/com/android/messaging/ui/conversation/v2/mapper/ConversationMessageUiModelMapper.kt @@ -23,6 +23,11 @@ internal class ConversationMessageUiModelMapperImpl @Inject constructor() : Conv parts = data.parts?.map(::mapPart) ?: emptyList(), sentTimestamp = data.sentTimeStamp, receivedTimestamp = data.receivedTimeStamp, + displayTimestamp = conversationMessageDisplayTimestamp( + sentTimestamp = data.sentTimeStamp, + receivedTimestamp = data.receivedTimeStamp, + isIncoming = data.isIncoming, + ), status = mapStatus(data.status), isIncoming = data.isIncoming, senderDisplayName = data.senderDisplayName, @@ -99,6 +104,26 @@ internal class ConversationMessageUiModelMapperImpl @Inject constructor() : Conv } } + private fun conversationMessageDisplayTimestamp( + sentTimestamp: Long, + receivedTimestamp: Long, + isIncoming: Boolean, + ): Long { + val primaryTimestamp = when { + isIncoming -> receivedTimestamp + else -> sentTimestamp + } + + if (primaryTimestamp > 0L) { + return primaryTimestamp + } + + return when { + isIncoming -> sentTimestamp + else -> receivedTimestamp + } + } + private companion object { private const val LOG_TAG = "ConversationMessageUiModelMapper" } diff --git a/src/com/android/messaging/ui/conversation/v2/mapper/ConversationMetadataUiStateMapper.kt b/src/com/android/messaging/ui/conversation/v2/mapper/ConversationMetadataUiStateMapper.kt new file mode 100644 index 00000000..44214352 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/v2/mapper/ConversationMetadataUiStateMapper.kt @@ -0,0 +1,21 @@ +package com.android.messaging.ui.conversation.v2.mapper + +import com.android.messaging.data.conversation.repository.ConversationMetadata +import com.android.messaging.ui.conversation.v2.model.ConversationMetadataUiState +import javax.inject.Inject + +internal interface ConversationMetadataUiStateMapper { + fun map(metadata: ConversationMetadata): ConversationMetadataUiState +} + +internal class ConversationMetadataUiStateMapperImpl @Inject constructor() : ConversationMetadataUiStateMapper { + + override fun map(metadata: ConversationMetadata): ConversationMetadataUiState { + return ConversationMetadataUiState.Present( + title = metadata.conversationName, + selfParticipantId = metadata.selfParticipantId, + isGroupConversation = metadata.isGroupConversation, + participantCount = metadata.participantCount, + ) + } +} diff --git a/src/com/android/messaging/ui/conversation/v2/model/ConversationMessageUiModel.kt b/src/com/android/messaging/ui/conversation/v2/model/ConversationMessageUiModel.kt index b7a1bffd..db35c330 100644 --- a/src/com/android/messaging/ui/conversation/v2/model/ConversationMessageUiModel.kt +++ b/src/com/android/messaging/ui/conversation/v2/model/ConversationMessageUiModel.kt @@ -12,6 +12,7 @@ internal data class ConversationMessageUiModel( val parts: List, val sentTimestamp: Long, val receivedTimestamp: Long, + val displayTimestamp: Long, val status: Status, val isIncoming: Boolean, val senderDisplayName: String?, diff --git a/src/com/android/messaging/ui/conversation/v2/model/ConversationMessagesUiState.kt b/src/com/android/messaging/ui/conversation/v2/model/ConversationMessagesUiState.kt new file mode 100644 index 00000000..14d58396 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/v2/model/ConversationMessagesUiState.kt @@ -0,0 +1,15 @@ +package com.android.messaging.ui.conversation.v2.model + +import androidx.compose.runtime.Immutable + +@Immutable +internal sealed interface ConversationMessagesUiState { + + @Immutable + data object Loading : ConversationMessagesUiState + + @Immutable + data class Present( + val messages: List = emptyList(), + ) : ConversationMessagesUiState +} diff --git a/src/com/android/messaging/ui/conversation/v2/model/ConversationMetadataUiState.kt b/src/com/android/messaging/ui/conversation/v2/model/ConversationMetadataUiState.kt new file mode 100644 index 00000000..50fb4ee7 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/v2/model/ConversationMetadataUiState.kt @@ -0,0 +1,18 @@ +package com.android.messaging.ui.conversation.v2.model + +import androidx.compose.runtime.Immutable + +@Immutable +internal sealed interface ConversationMetadataUiState { + + @Immutable + data object Loading : ConversationMetadataUiState + + @Immutable + data class Present( + val title: String = "", + val selfParticipantId: String = "", + val isGroupConversation: Boolean = false, + val participantCount: Int = 0, + ) : ConversationMetadataUiState +} diff --git a/src/com/android/messaging/ui/conversation/v2/model/ConversationUiState.kt b/src/com/android/messaging/ui/conversation/v2/model/ConversationUiState.kt index 675da871..5f12eda2 100644 --- a/src/com/android/messaging/ui/conversation/v2/model/ConversationUiState.kt +++ b/src/com/android/messaging/ui/conversation/v2/model/ConversationUiState.kt @@ -1,20 +1,9 @@ package com.android.messaging.ui.conversation.v2.model import androidx.compose.runtime.Immutable -import androidx.compose.runtime.Stable -@Stable -internal sealed interface ConversationUiState { - - data object Loading : ConversationUiState - - @Immutable - data class Present( - val conversationName: String = "", - val selfParticipantId: String = "", - val isGroupConversation: Boolean = false, - val messages: List = emptyList(), - // TODO: Draft - - ) : ConversationUiState -} +@Immutable +internal data class ConversationUiState( + val metadata: ConversationMetadataUiState = ConversationMetadataUiState.Loading, + val messages: ConversationMessagesUiState = ConversationMessagesUiState.Loading, +) From ee067ffb16dd143d7c2a1420fccc3f64aba96121 Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Wed, 25 Mar 2026 23:57:42 +0200 Subject: [PATCH 05/99] Add Kotlin Flow extensions --- .../util/core/extension/KotlinFlowExtensions.kt | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 src/com/android/messaging/util/core/extension/KotlinFlowExtensions.kt diff --git a/src/com/android/messaging/util/core/extension/KotlinFlowExtensions.kt b/src/com/android/messaging/util/core/extension/KotlinFlowExtensions.kt new file mode 100644 index 00000000..7ee4b6ba --- /dev/null +++ b/src/com/android/messaging/util/core/extension/KotlinFlowExtensions.kt @@ -0,0 +1,15 @@ +package com.android.messaging.util.core.extension + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.FlowCollector +import kotlinx.coroutines.flow.flow + +inline fun typedFlow( + crossinline block: suspend FlowCollector.() -> T +): Flow { + return flow { + val value = block() + + emit(value) + } +} From b82f75ba42aab6eaad7c546bee9cc65ad105e928 Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Thu, 26 Mar 2026 22:51:48 +0200 Subject: [PATCH 06/99] Improve Conversation screen package structure --- .../di/conversation/ConversationBindsModule.kt | 14 +++++++------- .../ui/conversation/v2/ConversationActivity.kt | 1 + .../ui/conversation/v2/ConversationScreen.kt | 11 +++++------ .../ui/conversation/v2/ConversationViewModel.kt | 11 +++++------ .../v2/component/ConversationComposeBar.kt | 2 +- .../v2/component/ConversationMessage.kt | 4 ++-- .../v2/component/ConversationMessages.kt | 6 ++---- .../v2/component/ConversationTopAppBar.kt | 4 ++-- .../component/util/ConversationMessageDisplay.kt | 2 +- .../v2/mapper/ConversationMessageUiModelMapper.kt | 6 +++--- .../v2/mapper/ConversationMetadataUiStateMapper.kt | 4 ++-- .../v2/model/ConversationMessagePartUiModel.kt | 2 +- .../v2/model/ConversationMessageUiModel.kt | 2 +- .../v2/model/ConversationMessagesUiState.kt | 2 +- .../v2/model/ConversationMetadataUiState.kt | 2 +- .../conversation/v2/model/ConversationUiState.kt | 4 +++- 16 files changed, 38 insertions(+), 39 deletions(-) diff --git a/src/com/android/messaging/di/conversation/ConversationBindsModule.kt b/src/com/android/messaging/di/conversation/ConversationBindsModule.kt index fbc9823d..3dea3fb1 100644 --- a/src/com/android/messaging/di/conversation/ConversationBindsModule.kt +++ b/src/com/android/messaging/di/conversation/ConversationBindsModule.kt @@ -2,10 +2,10 @@ package com.android.messaging.di.conversation import com.android.messaging.data.conversation.repository.ConversationsRepository import com.android.messaging.data.conversation.repository.ConversationsRepositoryImpl -import com.android.messaging.ui.conversation.v2.mapper.ConversationMetadataUiStateMapper -import com.android.messaging.ui.conversation.v2.mapper.ConversationMetadataUiStateMapperImpl -import com.android.messaging.ui.conversation.v2.mapper.ConversationMessageUiModelMapper -import com.android.messaging.ui.conversation.v2.mapper.ConversationMessageUiModelMapperImpl +import com.android.messaging.ui.conversation.v2.messages.mapper.ConversationMessageUiModelMapper +import com.android.messaging.ui.conversation.v2.messages.mapper.ConversationMessageUiModelMapperImpl +import com.android.messaging.ui.conversation.v2.metadata.mapper.ConversationMetadataUiStateMapper +import com.android.messaging.ui.conversation.v2.metadata.mapper.ConversationMetadataUiStateMapperImpl import dagger.Binds import dagger.Module import dagger.Reusable @@ -18,17 +18,17 @@ internal abstract class ConversationBindsModule { @Binds @Reusable - abstract fun provideConversationsRepository( + abstract fun bindConversationsRepository( impl: ConversationsRepositoryImpl, ): ConversationsRepository @Binds - abstract fun provideConversationMessageUiModelMapper( + abstract fun bindConversationMessageUiModelMapper( impl: ConversationMessageUiModelMapperImpl, ): ConversationMessageUiModelMapper @Binds - abstract fun provideConversationMetadataUiStateMapper( + abstract fun bindConversationMetadataUiStateMapper( impl: ConversationMetadataUiStateMapperImpl, ): ConversationMetadataUiStateMapper } diff --git a/src/com/android/messaging/ui/conversation/v2/ConversationActivity.kt b/src/com/android/messaging/ui/conversation/v2/ConversationActivity.kt index e7caa585..76d0f155 100644 --- a/src/com/android/messaging/ui/conversation/v2/ConversationActivity.kt +++ b/src/com/android/messaging/ui/conversation/v2/ConversationActivity.kt @@ -10,6 +10,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import com.android.messaging.ui.core.AppTheme import com.android.messaging.ui.UIIntents +import com.android.messaging.ui.conversation.v2.screen.ConversationScreen import dagger.hilt.android.AndroidEntryPoint @AndroidEntryPoint diff --git a/src/com/android/messaging/ui/conversation/v2/ConversationScreen.kt b/src/com/android/messaging/ui/conversation/v2/ConversationScreen.kt index 9d201c77..1415ebf5 100644 --- a/src/com/android/messaging/ui/conversation/v2/ConversationScreen.kt +++ b/src/com/android/messaging/ui/conversation/v2/ConversationScreen.kt @@ -1,4 +1,4 @@ -package com.android.messaging.ui.conversation.v2 +package com.android.messaging.ui.conversation.v2.screen import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize @@ -14,11 +14,10 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel -import com.android.messaging.ui.conversation.v2.component.ConversationComposeBar -import com.android.messaging.ui.conversation.v2.component.ConversationMessages -import com.android.messaging.ui.conversation.v2.component.ConversationTopAppBar -import com.android.messaging.ui.conversation.v2.model.ConversationMessagesUiState -import com.android.messaging.ui.conversation.v2.model.ConversationUiState +import com.android.messaging.ui.conversation.v2.composer.ui.ConversationComposeBar +import com.android.messaging.ui.conversation.v2.messages.model.ConversationMessagesUiState +import com.android.messaging.ui.conversation.v2.messages.ui.ConversationMessages +import com.android.messaging.ui.conversation.v2.metadata.ui.ConversationTopAppBar @Composable internal fun ConversationScreen( diff --git a/src/com/android/messaging/ui/conversation/v2/ConversationViewModel.kt b/src/com/android/messaging/ui/conversation/v2/ConversationViewModel.kt index 5304c93f..e2e04cd8 100644 --- a/src/com/android/messaging/ui/conversation/v2/ConversationViewModel.kt +++ b/src/com/android/messaging/ui/conversation/v2/ConversationViewModel.kt @@ -1,15 +1,14 @@ -package com.android.messaging.ui.conversation.v2 +package com.android.messaging.ui.conversation.v2.screen import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.android.messaging.data.conversation.repository.ConversationsRepository import com.android.messaging.di.core.DefaultDispatcher -import com.android.messaging.ui.conversation.v2.mapper.ConversationMessageUiModelMapper -import com.android.messaging.ui.conversation.v2.mapper.ConversationMetadataUiStateMapper -import com.android.messaging.ui.conversation.v2.model.ConversationMessagesUiState -import com.android.messaging.ui.conversation.v2.model.ConversationMetadataUiState -import com.android.messaging.ui.conversation.v2.model.ConversationUiState +import com.android.messaging.ui.conversation.v2.messages.mapper.ConversationMessageUiModelMapper +import com.android.messaging.ui.conversation.v2.messages.model.ConversationMessagesUiState +import com.android.messaging.ui.conversation.v2.metadata.mapper.ConversationMetadataUiStateMapper +import com.android.messaging.ui.conversation.v2.metadata.model.ConversationMetadataUiState import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.ExperimentalCoroutinesApi diff --git a/src/com/android/messaging/ui/conversation/v2/component/ConversationComposeBar.kt b/src/com/android/messaging/ui/conversation/v2/component/ConversationComposeBar.kt index 443bfddd..4546c910 100644 --- a/src/com/android/messaging/ui/conversation/v2/component/ConversationComposeBar.kt +++ b/src/com/android/messaging/ui/conversation/v2/component/ConversationComposeBar.kt @@ -1,4 +1,4 @@ -package com.android.messaging.ui.conversation.v2.component +package com.android.messaging.ui.conversation.v2.composer.ui import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement diff --git a/src/com/android/messaging/ui/conversation/v2/component/ConversationMessage.kt b/src/com/android/messaging/ui/conversation/v2/component/ConversationMessage.kt index 3d74ed6b..7c6be92b 100644 --- a/src/com/android/messaging/ui/conversation/v2/component/ConversationMessage.kt +++ b/src/com/android/messaging/ui/conversation/v2/component/ConversationMessage.kt @@ -1,4 +1,4 @@ -package com.android.messaging.ui.conversation.v2.component +package com.android.messaging.ui.conversation.v2.messages.ui import android.content.Context import android.text.format.DateUtils @@ -27,7 +27,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.android.messaging.R -import com.android.messaging.ui.conversation.v2.model.ConversationMessageUiModel +import com.android.messaging.ui.conversation.v2.messages.model.ConversationMessageUiModel private const val MESSAGE_BUBBLE_MAX_WIDTH_DP = 360 private const val MESSAGE_BUBBLE_WIDTH_FRACTION = 0.8f diff --git a/src/com/android/messaging/ui/conversation/v2/component/ConversationMessages.kt b/src/com/android/messaging/ui/conversation/v2/component/ConversationMessages.kt index 7fe40f04..04a4f9de 100644 --- a/src/com/android/messaging/ui/conversation/v2/component/ConversationMessages.kt +++ b/src/com/android/messaging/ui/conversation/v2/component/ConversationMessages.kt @@ -1,4 +1,4 @@ -package com.android.messaging.ui.conversation.v2.component +package com.android.messaging.ui.conversation.v2.messages.ui import android.content.Context import android.text.format.DateUtils @@ -23,9 +23,7 @@ import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import com.android.messaging.ui.conversation.v2.component.util.conversationMessageDisplayEpochDay -import com.android.messaging.ui.conversation.v2.component.util.conversationMessageDisplayLocalDate -import com.android.messaging.ui.conversation.v2.model.ConversationMessageUiModel +import com.android.messaging.ui.conversation.v2.messages.model.ConversationMessageUiModel import java.time.LocalDate import java.util.TimeZone diff --git a/src/com/android/messaging/ui/conversation/v2/component/ConversationTopAppBar.kt b/src/com/android/messaging/ui/conversation/v2/component/ConversationTopAppBar.kt index f1a2f72b..05c69a93 100644 --- a/src/com/android/messaging/ui/conversation/v2/component/ConversationTopAppBar.kt +++ b/src/com/android/messaging/ui/conversation/v2/component/ConversationTopAppBar.kt @@ -1,4 +1,4 @@ -package com.android.messaging.ui.conversation.v2.component +package com.android.messaging.ui.conversation.v2.metadata.ui import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -32,7 +32,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.android.messaging.R -import com.android.messaging.ui.conversation.v2.model.ConversationMetadataUiState +import com.android.messaging.ui.conversation.v2.metadata.model.ConversationMetadataUiState private val CONVERSATION_TOP_APP_BAR_TITLE_SPACING = 12.dp private val CONVERSATION_TOP_APP_BAR_AVATAR_SIZE = 36.dp diff --git a/src/com/android/messaging/ui/conversation/v2/component/util/ConversationMessageDisplay.kt b/src/com/android/messaging/ui/conversation/v2/component/util/ConversationMessageDisplay.kt index 61639e28..3e249d3f 100644 --- a/src/com/android/messaging/ui/conversation/v2/component/util/ConversationMessageDisplay.kt +++ b/src/com/android/messaging/ui/conversation/v2/component/util/ConversationMessageDisplay.kt @@ -1,4 +1,4 @@ -package com.android.messaging.ui.conversation.v2.component.util +package com.android.messaging.ui.conversation.v2.messages.ui import java.time.Instant import java.time.LocalDate diff --git a/src/com/android/messaging/ui/conversation/v2/mapper/ConversationMessageUiModelMapper.kt b/src/com/android/messaging/ui/conversation/v2/mapper/ConversationMessageUiModelMapper.kt index 43d98a0e..577e734f 100644 --- a/src/com/android/messaging/ui/conversation/v2/mapper/ConversationMessageUiModelMapper.kt +++ b/src/com/android/messaging/ui/conversation/v2/mapper/ConversationMessageUiModelMapper.kt @@ -1,11 +1,11 @@ -package com.android.messaging.ui.conversation.v2.mapper +package com.android.messaging.ui.conversation.v2.messages.mapper import android.util.Log import com.android.messaging.datamodel.data.ConversationMessageData import com.android.messaging.datamodel.data.MessageData import com.android.messaging.datamodel.data.MessagePartData -import com.android.messaging.ui.conversation.v2.model.ConversationMessagePartUiModel -import com.android.messaging.ui.conversation.v2.model.ConversationMessageUiModel +import com.android.messaging.ui.conversation.v2.messages.model.ConversationMessagePartUiModel +import com.android.messaging.ui.conversation.v2.messages.model.ConversationMessageUiModel import javax.inject.Inject internal interface ConversationMessageUiModelMapper { diff --git a/src/com/android/messaging/ui/conversation/v2/mapper/ConversationMetadataUiStateMapper.kt b/src/com/android/messaging/ui/conversation/v2/mapper/ConversationMetadataUiStateMapper.kt index 44214352..68385b86 100644 --- a/src/com/android/messaging/ui/conversation/v2/mapper/ConversationMetadataUiStateMapper.kt +++ b/src/com/android/messaging/ui/conversation/v2/mapper/ConversationMetadataUiStateMapper.kt @@ -1,7 +1,7 @@ -package com.android.messaging.ui.conversation.v2.mapper +package com.android.messaging.ui.conversation.v2.metadata.mapper import com.android.messaging.data.conversation.repository.ConversationMetadata -import com.android.messaging.ui.conversation.v2.model.ConversationMetadataUiState +import com.android.messaging.ui.conversation.v2.metadata.model.ConversationMetadataUiState import javax.inject.Inject internal interface ConversationMetadataUiStateMapper { diff --git a/src/com/android/messaging/ui/conversation/v2/model/ConversationMessagePartUiModel.kt b/src/com/android/messaging/ui/conversation/v2/model/ConversationMessagePartUiModel.kt index a273b39c..e327cbd8 100644 --- a/src/com/android/messaging/ui/conversation/v2/model/ConversationMessagePartUiModel.kt +++ b/src/com/android/messaging/ui/conversation/v2/model/ConversationMessagePartUiModel.kt @@ -1,4 +1,4 @@ -package com.android.messaging.ui.conversation.v2.model +package com.android.messaging.ui.conversation.v2.messages.model import android.net.Uri import androidx.compose.runtime.Immutable diff --git a/src/com/android/messaging/ui/conversation/v2/model/ConversationMessageUiModel.kt b/src/com/android/messaging/ui/conversation/v2/model/ConversationMessageUiModel.kt index db35c330..7b2dbbf5 100644 --- a/src/com/android/messaging/ui/conversation/v2/model/ConversationMessageUiModel.kt +++ b/src/com/android/messaging/ui/conversation/v2/model/ConversationMessageUiModel.kt @@ -1,4 +1,4 @@ -package com.android.messaging.ui.conversation.v2.model +package com.android.messaging.ui.conversation.v2.messages.model import android.net.Uri import androidx.compose.runtime.Immutable diff --git a/src/com/android/messaging/ui/conversation/v2/model/ConversationMessagesUiState.kt b/src/com/android/messaging/ui/conversation/v2/model/ConversationMessagesUiState.kt index 14d58396..51fa5317 100644 --- a/src/com/android/messaging/ui/conversation/v2/model/ConversationMessagesUiState.kt +++ b/src/com/android/messaging/ui/conversation/v2/model/ConversationMessagesUiState.kt @@ -1,4 +1,4 @@ -package com.android.messaging.ui.conversation.v2.model +package com.android.messaging.ui.conversation.v2.messages.model import androidx.compose.runtime.Immutable diff --git a/src/com/android/messaging/ui/conversation/v2/model/ConversationMetadataUiState.kt b/src/com/android/messaging/ui/conversation/v2/model/ConversationMetadataUiState.kt index 50fb4ee7..9676efc5 100644 --- a/src/com/android/messaging/ui/conversation/v2/model/ConversationMetadataUiState.kt +++ b/src/com/android/messaging/ui/conversation/v2/model/ConversationMetadataUiState.kt @@ -1,4 +1,4 @@ -package com.android.messaging.ui.conversation.v2.model +package com.android.messaging.ui.conversation.v2.metadata.model import androidx.compose.runtime.Immutable diff --git a/src/com/android/messaging/ui/conversation/v2/model/ConversationUiState.kt b/src/com/android/messaging/ui/conversation/v2/model/ConversationUiState.kt index 5f12eda2..5c3acc54 100644 --- a/src/com/android/messaging/ui/conversation/v2/model/ConversationUiState.kt +++ b/src/com/android/messaging/ui/conversation/v2/model/ConversationUiState.kt @@ -1,6 +1,8 @@ -package com.android.messaging.ui.conversation.v2.model +package com.android.messaging.ui.conversation.v2.screen import androidx.compose.runtime.Immutable +import com.android.messaging.ui.conversation.v2.messages.model.ConversationMessagesUiState +import com.android.messaging.ui.conversation.v2.metadata.model.ConversationMetadataUiState @Immutable internal data class ConversationUiState( From eecbae6c0395947ca365d2cb8bcb68c611ee18a8 Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Fri, 27 Mar 2026 12:43:45 +0200 Subject: [PATCH 07/99] Add draft/composer state and delegate architecture --- .../model/draft/ConversationDraft.kt | 20 ++ .../draft/ConversationDraftAttachment.kt | 9 + .../ConversationComposerAvailability.kt | 31 ++ .../ConversationComposerDisabledReason.kt | 6 + .../metadata}/ConversationMetadata.kt | 3 +- .../ConversationDraftsRepository.kt | 271 ++++++++++++++ .../repository/ConversationsRepository.kt | 3 + .../conversation/ConversationBindsModule.kt | 36 ++ .../messaging/di/core/CoreProvidesModule.kt | 13 + .../android/messaging/di/core/Qualifiers.kt | 4 + .../conversation/v2/ConversationViewModel.kt | 110 ------ .../v2/common/ConversationScreenDelegate.kt | 13 + .../delegate/ConversationDraftDelegate.kt | 335 ++++++++++++++++++ .../delegate/ConversationMessagesDelegate.kt | 70 ++++ .../delegate/ConversationMetadataDelegate.kt | 68 ++++ .../v2/screen/ConversationViewModel.kt | 103 ++++++ 16 files changed, 984 insertions(+), 111 deletions(-) create mode 100644 src/com/android/messaging/data/conversation/model/draft/ConversationDraft.kt create mode 100644 src/com/android/messaging/data/conversation/model/draft/ConversationDraftAttachment.kt create mode 100644 src/com/android/messaging/data/conversation/model/metadata/ConversationComposerAvailability.kt create mode 100644 src/com/android/messaging/data/conversation/model/metadata/ConversationComposerDisabledReason.kt rename src/com/android/messaging/data/conversation/{repository => model/metadata}/ConversationMetadata.kt (59%) create mode 100644 src/com/android/messaging/data/conversation/repository/ConversationDraftsRepository.kt delete mode 100644 src/com/android/messaging/ui/conversation/v2/ConversationViewModel.kt create mode 100644 src/com/android/messaging/ui/conversation/v2/common/ConversationScreenDelegate.kt create mode 100644 src/com/android/messaging/ui/conversation/v2/composer/delegate/ConversationDraftDelegate.kt create mode 100644 src/com/android/messaging/ui/conversation/v2/messages/delegate/ConversationMessagesDelegate.kt create mode 100644 src/com/android/messaging/ui/conversation/v2/metadata/delegate/ConversationMetadataDelegate.kt create mode 100644 src/com/android/messaging/ui/conversation/v2/screen/ConversationViewModel.kt diff --git a/src/com/android/messaging/data/conversation/model/draft/ConversationDraft.kt b/src/com/android/messaging/data/conversation/model/draft/ConversationDraft.kt new file mode 100644 index 00000000..a87c73ca --- /dev/null +++ b/src/com/android/messaging/data/conversation/model/draft/ConversationDraft.kt @@ -0,0 +1,20 @@ +package com.android.messaging.data.conversation.model.draft + +internal data class ConversationDraft( + val messageText: String = "", + val subjectText: String = "", + val selfParticipantId: String = "", + val attachments: List = emptyList(), + val isCheckingDraft: Boolean = false, + val isSending: Boolean = false, + val messageCount: Int = 1, + val codePointsRemainingInCurrentMessage: Int = 0, +) { + val hasContent: Boolean + get() = messageText.isNotBlank() || + subjectText.isNotBlank() || + attachments.isNotEmpty() + + val isMms: Boolean + get() = subjectText.isNotBlank() || attachments.isNotEmpty() +} diff --git a/src/com/android/messaging/data/conversation/model/draft/ConversationDraftAttachment.kt b/src/com/android/messaging/data/conversation/model/draft/ConversationDraftAttachment.kt new file mode 100644 index 00000000..af20005b --- /dev/null +++ b/src/com/android/messaging/data/conversation/model/draft/ConversationDraftAttachment.kt @@ -0,0 +1,9 @@ +package com.android.messaging.data.conversation.model.draft + +internal data class ConversationDraftAttachment( + val contentType: String, + val contentUri: String, + val captionText: String = "", + val width: Int? = null, + val height: Int? = null, +) diff --git a/src/com/android/messaging/data/conversation/model/metadata/ConversationComposerAvailability.kt b/src/com/android/messaging/data/conversation/model/metadata/ConversationComposerAvailability.kt new file mode 100644 index 00000000..cdfd85c7 --- /dev/null +++ b/src/com/android/messaging/data/conversation/model/metadata/ConversationComposerAvailability.kt @@ -0,0 +1,31 @@ +package com.android.messaging.data.conversation.model.metadata + +internal data class ConversationComposerAvailability( + val isMessageFieldEnabled: Boolean, + val isAttachmentActionEnabled: Boolean, + val isSendAvailable: Boolean, + val disabledReason: ConversationComposerDisabledReason?, +) { + + companion object { + fun editable(): ConversationComposerAvailability { + return ConversationComposerAvailability( + isMessageFieldEnabled = true, + isAttachmentActionEnabled = true, + isSendAvailable = true, + disabledReason = null, + ) + } + + fun unavailable( + reason: ConversationComposerDisabledReason, + ): ConversationComposerAvailability { + return ConversationComposerAvailability( + isMessageFieldEnabled = false, + isAttachmentActionEnabled = false, + isSendAvailable = false, + disabledReason = reason, + ) + } + } +} diff --git a/src/com/android/messaging/data/conversation/model/metadata/ConversationComposerDisabledReason.kt b/src/com/android/messaging/data/conversation/model/metadata/ConversationComposerDisabledReason.kt new file mode 100644 index 00000000..84e50d09 --- /dev/null +++ b/src/com/android/messaging/data/conversation/model/metadata/ConversationComposerDisabledReason.kt @@ -0,0 +1,6 @@ +package com.android.messaging.data.conversation.model.metadata + +internal enum class ConversationComposerDisabledReason { + CONVERSATION_UNAVAILABLE, + READ_ONLY_CONVERSATION, +} diff --git a/src/com/android/messaging/data/conversation/repository/ConversationMetadata.kt b/src/com/android/messaging/data/conversation/model/metadata/ConversationMetadata.kt similarity index 59% rename from src/com/android/messaging/data/conversation/repository/ConversationMetadata.kt rename to src/com/android/messaging/data/conversation/model/metadata/ConversationMetadata.kt index 1ed76830..0b0c011b 100644 --- a/src/com/android/messaging/data/conversation/repository/ConversationMetadata.kt +++ b/src/com/android/messaging/data/conversation/model/metadata/ConversationMetadata.kt @@ -1,8 +1,9 @@ -package com.android.messaging.data.conversation.repository +package com.android.messaging.data.conversation.model.metadata internal data class ConversationMetadata( val conversationName: String, val selfParticipantId: String, val isGroupConversation: Boolean, val participantCount: Int, + val composerAvailability: ConversationComposerAvailability, ) diff --git a/src/com/android/messaging/data/conversation/repository/ConversationDraftsRepository.kt b/src/com/android/messaging/data/conversation/repository/ConversationDraftsRepository.kt new file mode 100644 index 00000000..dcfe4a2a --- /dev/null +++ b/src/com/android/messaging/data/conversation/repository/ConversationDraftsRepository.kt @@ -0,0 +1,271 @@ +package com.android.messaging.data.conversation.repository + +import android.content.ContentResolver +import android.database.ContentObserver +import android.net.Uri +import androidx.core.net.toUri +import com.android.messaging.data.conversation.model.draft.ConversationDraft +import com.android.messaging.data.conversation.model.draft.ConversationDraftAttachment +import com.android.messaging.datamodel.BugleDatabaseOperations +import com.android.messaging.datamodel.DataModel +import com.android.messaging.datamodel.MessagingContentProvider +import com.android.messaging.datamodel.data.ConversationListItemData +import com.android.messaging.datamodel.data.MessageData +import com.android.messaging.datamodel.data.MessagePartData +import com.android.messaging.di.core.IoDispatcher +import com.android.messaging.util.LogUtil +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.conflate +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.withContext +import javax.inject.Inject + +internal interface ConversationDraftsRepository { + fun observeConversationDraft(conversationId: String): Flow + + suspend fun saveDraft( + conversationId: String, + draft: ConversationDraft, + ) +} + +internal class ConversationDraftsRepositoryImpl @Inject constructor( + private val contentResolver: ContentResolver, + @param:IoDispatcher + private val ioDispatcher: CoroutineDispatcher, +) : ConversationDraftsRepository { + + override fun observeConversationDraft(conversationId: String): Flow { + val draftChangeUri = MessagingContentProvider.buildConversationMetadataUri(conversationId) + + return observeDraftChanges(uri = draftChangeUri) + .conflate() + .map { loadConversationDraft(conversationId = conversationId) } + .flowOn(ioDispatcher) + } + + override suspend fun saveDraft( + conversationId: String, + draft: ConversationDraft, + ) { + withContext(context = ioDispatcher) { + val message = createDraftMessage( + conversationId = conversationId, + draft = draft, + ) + val boundMessage = bindDraftParticipantsIfNeeded( + conversationId = conversationId, + message = message, + ) ?: return@withContext + + BugleDatabaseOperations.updateDraftMessageData( + DataModel.get().database, + conversationId, + boundMessage, + BugleDatabaseOperations.UPDATE_MODE_ADD_DRAFT, + ) + + MessagingContentProvider.notifyConversationMetadataChanged(conversationId) + } + } + + private fun observeDraftChanges(uri: Uri): Flow { + return callbackFlow { + val observer = object : ContentObserver(null) { + override fun onChange(selfChange: Boolean) { + trySend(Unit) + } + } + + contentResolver.registerContentObserver(uri, true, observer) + trySend(Unit) + + awaitClose { + contentResolver.unregisterContentObserver(observer) + } + } + } + + private fun loadConversationDraft(conversationId: String): ConversationDraft { + val database = DataModel.get().database + val conversation = ConversationListItemData + .getExistingConversation(database, conversationId) + ?: return ConversationDraft() + + val draftMessage = BugleDatabaseOperations.readDraftMessageData( + database, + conversationId, + conversation.selfId, + ) + + return createConversationDraft( + conversation = conversation, + draftMessage = draftMessage, + ) + } + + private fun createConversationDraft( + conversation: ConversationListItemData, + draftMessage: MessageData?, + ): ConversationDraft { + val attachments = draftMessage + ?.parts + ?.asSequence() + ?.filter { part -> part.isAttachment } + ?.mapNotNull(::createDraftAttachmentOrNull) + ?.toList() + ?: emptyList() + + val selfParticipantId = draftMessage + ?.selfId + ?.takeIf { selfParticipantId -> selfParticipantId.isNotBlank() } + ?: conversation.selfId.orEmpty() + + return ConversationDraft( + messageText = draftMessage?.messageText.orEmpty(), + subjectText = draftMessage?.mmsSubject.orEmpty(), + selfParticipantId = selfParticipantId, + attachments = attachments, + ) + } + + private fun createDraftMessage( + conversationId: String, + draft: ConversationDraft, + ): MessageData { + val selfParticipantId = draft.selfParticipantId.takeIf { selfParticipantId -> + selfParticipantId.isNotBlank() + } + val messageParts = draft.attachments.mapNotNull(::createMessagePartDataOrNull) + + val isMms = draft.subjectText.isNotBlank() || messageParts.isNotEmpty() + + val message = when { + isMms -> MessageData.createDraftMmsMessage( + conversationId, + selfParticipantId, + draft.messageText, + draft.subjectText, + ) + + else -> MessageData.createDraftSmsMessage( + conversationId, + selfParticipantId, + draft.messageText, + ) + } + + messageParts.forEach(message::addPart) + + return message + } + + private fun bindDraftParticipantsIfNeeded( + conversationId: String, + message: MessageData, + ): MessageData? { + if (message.selfId != null && message.participantId != null) { + return message + } + + val conversation = ConversationListItemData.getExistingConversation( + DataModel.get().database, + conversationId, + ) ?: run { + LogUtil.w( + TAG, + "Conversation $conversationId was deleted before saving draft ${message.messageId}", + ) + return null + } + + val selfParticipantId = conversation.selfId + if (message.selfId == null) { + message.bindSelfId(selfParticipantId) + } + if (message.participantId == null) { + message.bindParticipantId(selfParticipantId) + } + + return message + } + + private fun createDraftAttachmentOrNull(part: MessagePartData): ConversationDraftAttachment? { + val contentType = part + .contentType + ?.takeIf { value -> value.isNotBlank() } + ?: run { + LogUtil.w(TAG, "Dropping draft attachment with blank contentType") + return null + } + + val contentUri = part + .contentUri + ?.toString() + ?.takeIf { value -> value.isNotBlank() } + ?: run { + LogUtil.w(TAG, "Dropping draft attachment with blank contentUri") + return null + } + + return ConversationDraftAttachment( + contentType = contentType, + contentUri = contentUri, + captionText = part.text.orEmpty(), + width = normalizePartDimension(size = part.width), + height = normalizePartDimension(size = part.height), + ) + } + + private fun createMessagePartDataOrNull( + attachment: ConversationDraftAttachment, + ): MessagePartData? { + if (attachment.contentType.isBlank()) { + LogUtil.w(TAG, "Dropping draft attachment with blank contentType during save") + return null + } + + if (attachment.contentUri.isBlank()) { + LogUtil.w(TAG, "Dropping draft attachment with blank contentUri during save") + return null + } + + val captionText = attachment.captionText.takeIf { value -> value.isNotBlank() } + val contentUri = attachment.contentUri.toUri() + val width = toLegacyPartDimension(size = attachment.width) + val height = toLegacyPartDimension(size = attachment.height) + + captionText?.let { nonBlankCaptionText -> + return MessagePartData.createMediaMessagePart( + nonBlankCaptionText, + attachment.contentType, + contentUri, + width, + height, + ) + } + + return MessagePartData.createMediaMessagePart( + attachment.contentType, + contentUri, + width, + height, + ) + } + + private fun normalizePartDimension(size: Int): Int? { + return size.takeIf { it != MessagePartData.UNSPECIFIED_SIZE } + } + + private fun toLegacyPartDimension(size: Int?): Int { + return size ?: MessagePartData.UNSPECIFIED_SIZE + } + + private companion object { + private const val TAG = "ConversationDraftsRepository" + } +} diff --git a/src/com/android/messaging/data/conversation/repository/ConversationsRepository.kt b/src/com/android/messaging/data/conversation/repository/ConversationsRepository.kt index 8bce6914..a13a092f 100644 --- a/src/com/android/messaging/data/conversation/repository/ConversationsRepository.kt +++ b/src/com/android/messaging/data/conversation/repository/ConversationsRepository.kt @@ -3,6 +3,8 @@ package com.android.messaging.data.conversation.repository import android.content.ContentResolver import android.database.ContentObserver import android.net.Uri +import com.android.messaging.data.conversation.model.metadata.ConversationComposerAvailability +import com.android.messaging.data.conversation.model.metadata.ConversationMetadata import com.android.messaging.datamodel.DatabaseHelper.ConversationColumns import com.android.messaging.datamodel.MessagingContentProvider import com.android.messaging.datamodel.data.ConversationListItemData @@ -99,6 +101,7 @@ internal class ConversationsRepositoryImpl @Inject constructor( participantCount = cursor.getInt( cursor.getColumnIndexOrThrow(ConversationColumns.PARTICIPANT_COUNT), ), + composerAvailability = ConversationComposerAvailability.editable(), ) } } diff --git a/src/com/android/messaging/di/conversation/ConversationBindsModule.kt b/src/com/android/messaging/di/conversation/ConversationBindsModule.kt index 3dea3fb1..7d71ce02 100644 --- a/src/com/android/messaging/di/conversation/ConversationBindsModule.kt +++ b/src/com/android/messaging/di/conversation/ConversationBindsModule.kt @@ -1,9 +1,19 @@ package com.android.messaging.di.conversation +import com.android.messaging.data.conversation.repository.ConversationDraftsRepository +import com.android.messaging.data.conversation.repository.ConversationDraftsRepositoryImpl import com.android.messaging.data.conversation.repository.ConversationsRepository import com.android.messaging.data.conversation.repository.ConversationsRepositoryImpl +import com.android.messaging.ui.conversation.v2.composer.delegate.ConversationDraftDelegate +import com.android.messaging.ui.conversation.v2.composer.delegate.ConversationDraftDelegateImpl +import com.android.messaging.ui.conversation.v2.composer.mapper.ConversationComposerUiStateMapper +import com.android.messaging.ui.conversation.v2.composer.mapper.ConversationComposerUiStateMapperImpl +import com.android.messaging.ui.conversation.v2.messages.delegate.ConversationMessagesDelegate +import com.android.messaging.ui.conversation.v2.messages.delegate.ConversationMessagesDelegateImpl import com.android.messaging.ui.conversation.v2.messages.mapper.ConversationMessageUiModelMapper import com.android.messaging.ui.conversation.v2.messages.mapper.ConversationMessageUiModelMapperImpl +import com.android.messaging.ui.conversation.v2.metadata.delegate.ConversationMetadataDelegate +import com.android.messaging.ui.conversation.v2.metadata.delegate.ConversationMetadataDelegateImpl import com.android.messaging.ui.conversation.v2.metadata.mapper.ConversationMetadataUiStateMapper import com.android.messaging.ui.conversation.v2.metadata.mapper.ConversationMetadataUiStateMapperImpl import dagger.Binds @@ -16,12 +26,38 @@ import dagger.hilt.components.SingletonComponent @InstallIn(SingletonComponent::class) internal abstract class ConversationBindsModule { + @Binds + @Reusable + abstract fun bindConversationDraftsRepository( + impl: ConversationDraftsRepositoryImpl, + ): ConversationDraftsRepository + @Binds @Reusable abstract fun bindConversationsRepository( impl: ConversationsRepositoryImpl, ): ConversationsRepository + @Binds + abstract fun bindConversationDraftDelegate( + impl: ConversationDraftDelegateImpl, + ): ConversationDraftDelegate + + @Binds + abstract fun bindConversationMessagesDelegate( + impl: ConversationMessagesDelegateImpl, + ): ConversationMessagesDelegate + + @Binds + abstract fun bindConversationMetadataDelegate( + impl: ConversationMetadataDelegateImpl, + ): ConversationMetadataDelegate + + @Binds + abstract fun bindConversationComposerUiStateMapper( + impl: ConversationComposerUiStateMapperImpl, + ): ConversationComposerUiStateMapper + @Binds abstract fun bindConversationMessageUiModelMapper( impl: ConversationMessageUiModelMapperImpl, diff --git a/src/com/android/messaging/di/core/CoreProvidesModule.kt b/src/com/android/messaging/di/core/CoreProvidesModule.kt index 8be96507..567e50c1 100644 --- a/src/com/android/messaging/di/core/CoreProvidesModule.kt +++ b/src/com/android/messaging/di/core/CoreProvidesModule.kt @@ -9,7 +9,10 @@ import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) @@ -36,6 +39,16 @@ internal class CoreProvidesModule { return Dispatchers.Main } + @Provides + @Singleton + @ApplicationCoroutineScope + fun provideApplicationCoroutineScope( + @DefaultDispatcher + defaultDispatcher: CoroutineDispatcher, + ): CoroutineScope { + return CoroutineScope(SupervisorJob() + defaultDispatcher) + } + @Provides @Reusable fun provideContentResolver( diff --git a/src/com/android/messaging/di/core/Qualifiers.kt b/src/com/android/messaging/di/core/Qualifiers.kt index 4bf9b1ec..9cd64168 100644 --- a/src/com/android/messaging/di/core/Qualifiers.kt +++ b/src/com/android/messaging/di/core/Qualifiers.kt @@ -13,3 +13,7 @@ annotation class IoDispatcher @Retention(AnnotationRetention.BINARY) @Qualifier annotation class MainDispatcher + +@Retention(AnnotationRetention.BINARY) +@Qualifier +annotation class ApplicationCoroutineScope diff --git a/src/com/android/messaging/ui/conversation/v2/ConversationViewModel.kt b/src/com/android/messaging/ui/conversation/v2/ConversationViewModel.kt deleted file mode 100644 index e2e04cd8..00000000 --- a/src/com/android/messaging/ui/conversation/v2/ConversationViewModel.kt +++ /dev/null @@ -1,110 +0,0 @@ -package com.android.messaging.ui.conversation.v2.screen - -import androidx.lifecycle.SavedStateHandle -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.android.messaging.data.conversation.repository.ConversationsRepository -import com.android.messaging.di.core.DefaultDispatcher -import com.android.messaging.ui.conversation.v2.messages.mapper.ConversationMessageUiModelMapper -import com.android.messaging.ui.conversation.v2.messages.model.ConversationMessagesUiState -import com.android.messaging.ui.conversation.v2.metadata.mapper.ConversationMetadataUiStateMapper -import com.android.messaging.ui.conversation.v2.metadata.model.ConversationMetadataUiState -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.flow.flowOn -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.onStart -import kotlinx.coroutines.flow.stateIn -import javax.inject.Inject - -@OptIn(ExperimentalCoroutinesApi::class) -@HiltViewModel -internal class ConversationViewModel @Inject constructor( - private val conversationsRepository: ConversationsRepository, - private val conversationMetadataUiStateMapper: ConversationMetadataUiStateMapper, - private val conversationMessageUiModelMapper: ConversationMessageUiModelMapper, - @param:DefaultDispatcher - private val defaultDispatcher: CoroutineDispatcher, - private val savedStateHandle: SavedStateHandle, -) : ViewModel() { - - private val conversationIdFlow: StateFlow = savedStateHandle.getStateFlow( - key = CONVERSATION_ID_KEY, - initialValue = null, - ) - - val uiState: StateFlow = conversationIdFlow - .flatMapLatest { conversationId -> - observeConversationUiState(conversationId = conversationId) - } - .stateIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed( - stopTimeoutMillis = STATEFLOW_STOP_TIMEOUT_MILLIS, - ), - initialValue = ConversationUiState(), - ) - - var conversationId: String? - get() = conversationIdFlow.value - set(value) { - if (value != conversationIdFlow.value) { - savedStateHandle[CONVERSATION_ID_KEY] = value - } - } - - private fun observeConversationUiState(conversationId: String?): Flow { - if (conversationId == null) { - return flowOf(ConversationUiState()) - } - - return combine( - observeConversationMetadataUiState(conversationId = conversationId), - observeConversationMessagesUiState(conversationId = conversationId), - ) { metadata, messages -> - ConversationUiState( - metadata = metadata, - messages = messages, - ) - }.onStart { - emit(ConversationUiState()) - } - } - - private fun observeConversationMetadataUiState( - conversationId: String, - ): Flow { - return conversationsRepository - .getConversationMetadata(conversationId = conversationId) - .map { metadata -> - metadata - ?.let(conversationMetadataUiStateMapper::map) - ?: ConversationMetadataUiState.Present() - } - } - - private fun observeConversationMessagesUiState( - conversationId: String, - ): Flow { - return conversationsRepository - .getConversationMessages(conversationId = conversationId) - .map { messages -> - ConversationMessagesUiState.Present( - messages = messages.mapNotNull(conversationMessageUiModelMapper::map), - ) - } - .flowOn(defaultDispatcher) - } - - private companion object { - private const val CONVERSATION_ID_KEY = "conversation_id" - private const val STATEFLOW_STOP_TIMEOUT_MILLIS = 5_000L - } -} diff --git a/src/com/android/messaging/ui/conversation/v2/common/ConversationScreenDelegate.kt b/src/com/android/messaging/ui/conversation/v2/common/ConversationScreenDelegate.kt new file mode 100644 index 00000000..e8632baf --- /dev/null +++ b/src/com/android/messaging/ui/conversation/v2/common/ConversationScreenDelegate.kt @@ -0,0 +1,13 @@ +package com.android.messaging.ui.conversation.v2.common + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.StateFlow + +internal interface ConversationScreenDelegate { + val state: StateFlow + + fun bind( + scope: CoroutineScope, + conversationIdFlow: StateFlow, + ) +} diff --git a/src/com/android/messaging/ui/conversation/v2/composer/delegate/ConversationDraftDelegate.kt b/src/com/android/messaging/ui/conversation/v2/composer/delegate/ConversationDraftDelegate.kt new file mode 100644 index 00000000..6c326038 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/v2/composer/delegate/ConversationDraftDelegate.kt @@ -0,0 +1,335 @@ +package com.android.messaging.ui.conversation.v2.composer.delegate + +import com.android.messaging.data.conversation.model.draft.ConversationDraft +import com.android.messaging.data.conversation.model.draft.ConversationDraftAttachment +import com.android.messaging.data.conversation.repository.ConversationDraftsRepository +import com.android.messaging.di.core.ApplicationCoroutineScope +import com.android.messaging.di.core.DefaultDispatcher +import com.android.messaging.ui.conversation.v2.common.ConversationScreenDelegate +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import javax.inject.Inject + +internal interface ConversationDraftDelegate : ConversationScreenDelegate { + + fun onMessageTextChanged(messageText: String) + + fun persistDraft() + + fun flushDraft() +} + +@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) +internal class ConversationDraftDelegateImpl @Inject constructor( + @param:ApplicationCoroutineScope + private val applicationScope: CoroutineScope, + private val conversationDraftsRepository: ConversationDraftsRepository, + @param:DefaultDispatcher + private val defaultDispatcher: CoroutineDispatcher, +) : ConversationDraftDelegate { + + private val _state = MutableStateFlow(ConversationDraft()) + override val state = _state.asStateFlow() + + private val draftEditorState = MutableStateFlow(DraftEditorState()) + private val draftSaveMutex = Mutex() + + private var boundScope: CoroutineScope? = null + + override fun bind( + scope: CoroutineScope, + conversationIdFlow: StateFlow, + ) { + if (boundScope != null) { + return + } + + boundScope = scope + + bindConversationDraftObservation( + scope = scope, + conversationIdFlow = conversationIdFlow, + ) + bindDraftAutosave(scope = scope) + } + + override fun onMessageTextChanged(messageText: String) { + updateDraftEditorState { currentDraftEditorState -> + return@updateDraftEditorState currentDraftEditorState.withMessageText( + messageText = messageText, + ) + } + } + + override fun persistDraft() { + val currentDraftEditorState = draftEditorState.value + val scope = boundScope ?: return + + scope.launch(start = CoroutineStart.UNDISPATCHED) { + val saveRequest = currentDraftEditorState.toSaveRequestOrNull() ?: return@launch + + saveDraft( + saveRequest = saveRequest, + shouldMarkCurrentDraftAsPersisted = true, + ) + } + } + + override fun flushDraft() { + val saveRequest = draftEditorState.value.toSaveRequestOrNull() ?: return + + applicationScope.launch { + flushDraft(saveRequest = saveRequest) + } + } + + private suspend fun flushDraft(saveRequest: DraftSaveRequest) { + withContext(context = NonCancellable) { + saveDraft( + saveRequest = saveRequest, + shouldMarkCurrentDraftAsPersisted = false, + ) + } + } + + private suspend fun saveDraft( + saveRequest: DraftSaveRequest, + shouldMarkCurrentDraftAsPersisted: Boolean, + ) { + draftSaveMutex.withLock { + conversationDraftsRepository.saveDraft( + conversationId = saveRequest.conversationId, + draft = saveRequest.draft, + ) + + if (!shouldMarkCurrentDraftAsPersisted) { + return@withLock + } + + updateDraftEditorState { currentDraftEditorState -> + return@updateDraftEditorState currentDraftEditorState.markPersistedIfUnchanged( + saveRequest = saveRequest, + ) + } + } + } + + private fun bindConversationDraftObservation( + scope: CoroutineScope, + conversationIdFlow: StateFlow, + ) { + scope.launch(defaultDispatcher) { + conversationIdFlow.collectLatest { conversationId -> + resetDraftEditorState(conversationId = conversationId) + + if (conversationId == null) { + return@collectLatest + } + + observePersistedDraft(conversationId = conversationId) + } + } + } + + private fun bindDraftAutosave(scope: CoroutineScope) { + scope.launch(defaultDispatcher) { + draftEditorState + .map { currentDraftEditorState -> + currentDraftEditorState.toSaveRequestOrNull() + } + .distinctUntilChanged() + .debounce(timeoutMillis = DRAFT_AUTOSAVE_DELAY_MILLIS) + .filterNotNull() + .collect { saveRequest -> + saveDraft( + saveRequest = saveRequest, + shouldMarkCurrentDraftAsPersisted = true, + ) + } + } + } + + private suspend fun resetDraftEditorState(conversationId: String?) { + val previousDraftEditorState = draftEditorState.value + updateDraftEditorState( + draftEditorState = DraftEditorState( + conversationId = conversationId, + ), + ) + + previousDraftEditorState + .toSaveRequestOrNull() + ?.let { saveRequest -> + flushDraft(saveRequest = saveRequest) + } + } + + private suspend fun observePersistedDraft(conversationId: String) { + conversationDraftsRepository + .observeConversationDraft(conversationId = conversationId) + .collect { persistedDraft -> + updateDraftEditorState { currentDraftEditorState -> + if (currentDraftEditorState.conversationId != conversationId) { + return@updateDraftEditorState currentDraftEditorState + } + + return@updateDraftEditorState currentDraftEditorState.withPersistedDraft( + persistedDraft = persistedDraft, + ) + } + } + } + + private fun updateDraftEditorState(draftEditorState: DraftEditorState) { + this.draftEditorState.value = draftEditorState + _state.value = draftEditorState.visibleDraft + } + + private fun updateDraftEditorState(transform: (DraftEditorState) -> DraftEditorState) { + draftEditorState.update { currentDraftEditorState -> + val updatedDraftEditorState = transform(currentDraftEditorState) + _state.value = updatedDraftEditorState.visibleDraft + + updatedDraftEditorState + } + } + + private companion object { + private const val DRAFT_AUTOSAVE_DELAY_MILLIS = 300L + } +} + +private data class DraftEditorState( + val conversationId: String? = null, + val persistedDraft: ConversationDraft = ConversationDraft(), + val localEdits: ConversationDraftEdits = ConversationDraftEdits(), + val isLoaded: Boolean = false, +) { + val effectiveDraft: ConversationDraft + get() { + return localEdits.applyTo(baseDraft = persistedDraft) + } + + val visibleDraft: ConversationDraft + get() { + if (conversationId == null) { + return ConversationDraft() + } + + return effectiveDraft + } + + fun withPersistedDraft(persistedDraft: ConversationDraft): DraftEditorState { + return copy( + persistedDraft = persistedDraft, + localEdits = localEdits.normalizedAgainst( + baseDraft = persistedDraft, + ), + isLoaded = true, + ) + } + + fun withMessageText(messageText: String): DraftEditorState { + if (conversationId == null) { + return this + } + + return copy( + localEdits = localEdits + .copy(messageText = messageText) + .normalizedAgainst(baseDraft = persistedDraft), + ) + } + + fun toSaveRequestOrNull(): DraftSaveRequest? { + val currentConversationId = conversationId ?: return null + + if (!isLoaded || !localEdits.hasChanges) { + return null + } + + return DraftSaveRequest( + conversationId = currentConversationId, + draft = effectiveDraft, + ) + } + + fun markPersistedIfUnchanged(saveRequest: DraftSaveRequest): DraftEditorState { + if (conversationId != saveRequest.conversationId) { + return this + } + + if (effectiveDraft != saveRequest.draft) { + return this + } + + return copy( + persistedDraft = saveRequest.draft, + localEdits = ConversationDraftEdits(), + isLoaded = true, + ) + } +} + +private data class ConversationDraftEdits( + val messageText: String? = null, + val subjectText: String? = null, + val selfParticipantId: String? = null, + val attachments: List? = null, +) { + val hasChanges: Boolean + get() { + return messageText != null || + subjectText != null || + selfParticipantId != null || + attachments != null + } + + fun applyTo(baseDraft: ConversationDraft): ConversationDraft { + return baseDraft.copy( + messageText = messageText ?: baseDraft.messageText, + subjectText = subjectText ?: baseDraft.subjectText, + selfParticipantId = selfParticipantId ?: baseDraft.selfParticipantId, + attachments = attachments ?: baseDraft.attachments, + ) + } + + fun normalizedAgainst(baseDraft: ConversationDraft): ConversationDraftEdits { + return ConversationDraftEdits( + messageText = messageText?.takeUnless { value -> + value == baseDraft.messageText + }, + subjectText = subjectText?.takeUnless { value -> + value == baseDraft.subjectText + }, + selfParticipantId = selfParticipantId?.takeUnless { value -> + value == baseDraft.selfParticipantId + }, + attachments = attachments?.takeUnless { value -> + value == baseDraft.attachments + }, + ) + } +} + +private data class DraftSaveRequest( + val conversationId: String, + val draft: ConversationDraft, +) diff --git a/src/com/android/messaging/ui/conversation/v2/messages/delegate/ConversationMessagesDelegate.kt b/src/com/android/messaging/ui/conversation/v2/messages/delegate/ConversationMessagesDelegate.kt new file mode 100644 index 00000000..07e1fc9e --- /dev/null +++ b/src/com/android/messaging/ui/conversation/v2/messages/delegate/ConversationMessagesDelegate.kt @@ -0,0 +1,70 @@ +package com.android.messaging.ui.conversation.v2.messages.delegate + +import com.android.messaging.data.conversation.repository.ConversationsRepository +import com.android.messaging.di.core.DefaultDispatcher +import com.android.messaging.ui.conversation.v2.common.ConversationScreenDelegate +import com.android.messaging.ui.conversation.v2.messages.mapper.ConversationMessageUiModelMapper +import com.android.messaging.ui.conversation.v2.messages.model.ConversationMessagesUiState +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import javax.inject.Inject + +internal interface ConversationMessagesDelegate : + ConversationScreenDelegate + +internal class ConversationMessagesDelegateImpl @Inject constructor( + private val conversationsRepository: ConversationsRepository, + private val conversationMessageUiModelMapper: ConversationMessageUiModelMapper, + @param:DefaultDispatcher + private val defaultDispatcher: CoroutineDispatcher, +) : ConversationMessagesDelegate { + + private val _state = MutableStateFlow( + value = ConversationMessagesUiState.Loading, + ) + + override val state = _state.asStateFlow() + + private var isBound = false + + override fun bind( + scope: CoroutineScope, + conversationIdFlow: StateFlow, + ) { + if (isBound) { + return + } + + isBound = true + + scope.launch(defaultDispatcher) { + conversationIdFlow.collectLatest { conversationId -> + _state.value = ConversationMessagesUiState.Loading + + if (conversationId == null) { + return@collectLatest + } + + conversationsRepository + .getConversationMessages(conversationId = conversationId) + .map { messages -> + ConversationMessagesUiState.Present( + messages = messages + .mapNotNull(conversationMessageUiModelMapper::map), + ) + } + .flowOn(defaultDispatcher) + .collect { currentMessagesUiState -> + _state.value = currentMessagesUiState + } + } + } + } +} diff --git a/src/com/android/messaging/ui/conversation/v2/metadata/delegate/ConversationMetadataDelegate.kt b/src/com/android/messaging/ui/conversation/v2/metadata/delegate/ConversationMetadataDelegate.kt new file mode 100644 index 00000000..0bb72621 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/v2/metadata/delegate/ConversationMetadataDelegate.kt @@ -0,0 +1,68 @@ +package com.android.messaging.ui.conversation.v2.metadata.delegate + +import com.android.messaging.data.conversation.repository.ConversationsRepository +import com.android.messaging.di.core.DefaultDispatcher +import com.android.messaging.ui.conversation.v2.common.ConversationScreenDelegate +import com.android.messaging.ui.conversation.v2.metadata.mapper.ConversationMetadataUiStateMapper +import com.android.messaging.ui.conversation.v2.metadata.model.ConversationMetadataUiState +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import javax.inject.Inject + +internal interface ConversationMetadataDelegate : + ConversationScreenDelegate + +internal class ConversationMetadataDelegateImpl @Inject constructor( + private val conversationsRepository: ConversationsRepository, + private val conversationMetadataUiStateMapper: ConversationMetadataUiStateMapper, + @param:DefaultDispatcher + private val defaultDispatcher: CoroutineDispatcher, +) : ConversationMetadataDelegate { + + private val _state = MutableStateFlow( + value = ConversationMetadataUiState.Loading, + ) + override val state = _state.asStateFlow() + + private var isBound = false + + override fun bind( + scope: CoroutineScope, + conversationIdFlow: StateFlow, + ) { + if (isBound) { + return + } + + isBound = true + + scope.launch(defaultDispatcher) { + conversationIdFlow.collectLatest { conversationId -> + _state.value = ConversationMetadataUiState.Loading + + if (conversationId == null) { + return@collectLatest + } + + conversationsRepository + .getConversationMetadata(conversationId = conversationId) + .map { metadata -> + if (metadata == null) { + return@map ConversationMetadataUiState.Unavailable + } + + return@map conversationMetadataUiStateMapper.map(metadata = metadata) + } + .collect { currentMetadataState -> + _state.value = currentMetadataState + } + } + } + } +} diff --git a/src/com/android/messaging/ui/conversation/v2/screen/ConversationViewModel.kt b/src/com/android/messaging/ui/conversation/v2/screen/ConversationViewModel.kt new file mode 100644 index 00000000..1c100eed --- /dev/null +++ b/src/com/android/messaging/ui/conversation/v2/screen/ConversationViewModel.kt @@ -0,0 +1,103 @@ +package com.android.messaging.ui.conversation.v2.screen + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.android.messaging.ui.conversation.v2.composer.delegate.ConversationDraftDelegate +import com.android.messaging.ui.conversation.v2.composer.mapper.ConversationComposerUiStateMapper +import com.android.messaging.ui.conversation.v2.messages.delegate.ConversationMessagesDelegate +import com.android.messaging.ui.conversation.v2.metadata.delegate.ConversationMetadataDelegate +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import javax.inject.Inject + +@HiltViewModel +internal class ConversationViewModel @Inject constructor( + private val conversationDraftDelegate: ConversationDraftDelegate, + private val conversationMessagesDelegate: ConversationMessagesDelegate, + private val conversationMetadataDelegate: ConversationMetadataDelegate, + private val conversationComposerUiStateMapper: ConversationComposerUiStateMapper, + private val savedStateHandle: SavedStateHandle, +) : ViewModel() { + + private val conversationIdFlow: StateFlow = savedStateHandle.getStateFlow( + key = CONVERSATION_ID_KEY, + initialValue = null, + ) + + val uiState: StateFlow = combine( + conversationMetadataDelegate.state, + conversationMessagesDelegate.state, + conversationDraftDelegate.state, + ) { metadataState, messagesUiState, draft -> + return@combine ConversationUiState( + metadata = metadataState, + messages = messagesUiState, + composer = conversationComposerUiStateMapper.map( + draft = draft, + composerAvailability = metadataState.composerAvailability, + ), + ) + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed( + stopTimeoutMillis = STATEFLOW_STOP_TIMEOUT_MILLIS, + ), + initialValue = ConversationUiState(), + ) + + init { + initializeDelegates() + } + + private fun initializeDelegates() { + conversationDraftDelegate.bind( + scope = viewModelScope, + conversationIdFlow = conversationIdFlow, + ) + conversationMessagesDelegate.bind( + scope = viewModelScope, + conversationIdFlow = conversationIdFlow, + ) + conversationMetadataDelegate.bind( + scope = viewModelScope, + conversationIdFlow = conversationIdFlow, + ) + } + + fun onConversationChanged(conversationId: String?) { + if (conversationId != conversationIdFlow.value) { + savedStateHandle[CONVERSATION_ID_KEY] = conversationId + } + } + + fun onMessageTextChanged(text: String) { + conversationDraftDelegate.onMessageTextChanged(messageText = text) + } + + fun onAttachmentClick() { + // TODO + } + + fun onSendClick() { + // TODO + } + + fun persistDraft() { + conversationDraftDelegate.persistDraft() + } + + override fun onCleared() { + conversationDraftDelegate.flushDraft() + + super.onCleared() + } + + private companion object { + private const val CONVERSATION_ID_KEY = "conversation_id" + private const val STATEFLOW_STOP_TIMEOUT_MILLIS = 5_000L + } +} From a0517355db7b6589825f1aae654be4eed004a0f1 Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Fri, 27 Mar 2026 12:44:21 +0200 Subject: [PATCH 08/99] Wire conversation Compose UI to new state --- .../ConversationComposerUiStateMapper.kt | 47 ++++++++++++ .../model/ConversationComposerUiState.kt | 23 ++++++ .../ui}/ConversationComposeBar.kt | 73 ++++++++++++------- .../ConversationMessageUiModelMapper.kt | 0 .../model/ConversationMessagePartUiModel.kt | 0 .../model/ConversationMessageUiModel.kt | 0 .../model/ConversationMessagesUiState.kt | 0 .../ui}/ConversationMessage.kt | 0 .../ui}/ConversationMessageDisplay.kt | 0 .../ui}/ConversationMessages.kt | 53 +++++++------- .../ConversationMetadataUiStateMapper.kt | 3 +- .../model/ConversationMetadataUiState.kt | 33 +++++++++ .../ui}/ConversationTopAppBar.kt | 3 + .../v2/model/ConversationMetadataUiState.kt | 18 ----- .../v2/{ => screen}/ConversationScreen.kt | 27 ++++--- .../{model => screen}/ConversationUiState.kt | 2 + 16 files changed, 199 insertions(+), 83 deletions(-) create mode 100644 src/com/android/messaging/ui/conversation/v2/composer/mapper/ConversationComposerUiStateMapper.kt create mode 100644 src/com/android/messaging/ui/conversation/v2/composer/model/ConversationComposerUiState.kt rename src/com/android/messaging/ui/conversation/v2/{component => composer/ui}/ConversationComposeBar.kt (79%) rename src/com/android/messaging/ui/conversation/v2/{ => messages}/mapper/ConversationMessageUiModelMapper.kt (100%) rename src/com/android/messaging/ui/conversation/v2/{ => messages}/model/ConversationMessagePartUiModel.kt (100%) rename src/com/android/messaging/ui/conversation/v2/{ => messages}/model/ConversationMessageUiModel.kt (100%) rename src/com/android/messaging/ui/conversation/v2/{ => messages}/model/ConversationMessagesUiState.kt (100%) rename src/com/android/messaging/ui/conversation/v2/{component => messages/ui}/ConversationMessage.kt (100%) rename src/com/android/messaging/ui/conversation/v2/{component/util => messages/ui}/ConversationMessageDisplay.kt (100%) rename src/com/android/messaging/ui/conversation/v2/{component => messages/ui}/ConversationMessages.kt (88%) rename src/com/android/messaging/ui/conversation/v2/{ => metadata}/mapper/ConversationMetadataUiStateMapper.kt (84%) create mode 100644 src/com/android/messaging/ui/conversation/v2/metadata/model/ConversationMetadataUiState.kt rename src/com/android/messaging/ui/conversation/v2/{component => metadata/ui}/ConversationTopAppBar.kt (97%) delete mode 100644 src/com/android/messaging/ui/conversation/v2/model/ConversationMetadataUiState.kt rename src/com/android/messaging/ui/conversation/v2/{ => screen}/ConversationScreen.kt (76%) rename src/com/android/messaging/ui/conversation/v2/{model => screen}/ConversationUiState.kt (74%) diff --git a/src/com/android/messaging/ui/conversation/v2/composer/mapper/ConversationComposerUiStateMapper.kt b/src/com/android/messaging/ui/conversation/v2/composer/mapper/ConversationComposerUiStateMapper.kt new file mode 100644 index 00000000..05906cf6 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/v2/composer/mapper/ConversationComposerUiStateMapper.kt @@ -0,0 +1,47 @@ +package com.android.messaging.ui.conversation.v2.composer.mapper + +import com.android.messaging.data.conversation.model.draft.ConversationDraft +import com.android.messaging.data.conversation.model.metadata.ConversationComposerAvailability +import com.android.messaging.ui.conversation.v2.composer.model.ConversationComposerUiState +import javax.inject.Inject + +internal interface ConversationComposerUiStateMapper { + fun map( + draft: ConversationDraft, + composerAvailability: ConversationComposerAvailability, + ): ConversationComposerUiState +} + +internal class ConversationComposerUiStateMapperImpl @Inject constructor() : + ConversationComposerUiStateMapper { + + override fun map( + draft: ConversationDraft, + composerAvailability: ConversationComposerAvailability, + ): ConversationComposerUiState { + val hasWorkingDraft = draft.hasContent + + val isSendEnabled = composerAvailability.isSendAvailable && + hasWorkingDraft && + !draft.isCheckingDraft && + !draft.isSending + + return ConversationComposerUiState( + messageText = draft.messageText, + subjectText = draft.subjectText, + selfParticipantId = draft.selfParticipantId, + isMessageFieldEnabled = composerAvailability.isMessageFieldEnabled, + isAttachmentActionEnabled = composerAvailability.isAttachmentActionEnabled, + isSendEnabled = isSendEnabled, + hasWorkingDraft = hasWorkingDraft, + isMms = draft.isMms, + attachmentCount = draft.attachments.size, + pendingAttachmentCount = 0, + messageCount = draft.messageCount, + codePointsRemainingInCurrentMessage = draft.codePointsRemainingInCurrentMessage, + isCheckingDraft = draft.isCheckingDraft, + isSending = draft.isSending, + disabledReason = composerAvailability.disabledReason, + ) + } +} diff --git a/src/com/android/messaging/ui/conversation/v2/composer/model/ConversationComposerUiState.kt b/src/com/android/messaging/ui/conversation/v2/composer/model/ConversationComposerUiState.kt new file mode 100644 index 00000000..1c3a15a7 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/v2/composer/model/ConversationComposerUiState.kt @@ -0,0 +1,23 @@ +package com.android.messaging.ui.conversation.v2.composer.model + +import androidx.compose.runtime.Immutable +import com.android.messaging.data.conversation.model.metadata.ConversationComposerDisabledReason + +@Immutable +internal data class ConversationComposerUiState( + val messageText: String = "", + val subjectText: String = "", + val selfParticipantId: String = "", + val isMessageFieldEnabled: Boolean = false, + val isAttachmentActionEnabled: Boolean = false, + val isSendEnabled: Boolean = false, + val hasWorkingDraft: Boolean = false, + val isMms: Boolean = false, + val attachmentCount: Int = 0, + val pendingAttachmentCount: Int = 0, + val messageCount: Int = 1, + val codePointsRemainingInCurrentMessage: Int = 0, + val isCheckingDraft: Boolean = false, + val isSending: Boolean = false, + val disabledReason: ConversationComposerDisabledReason? = null, +) diff --git a/src/com/android/messaging/ui/conversation/v2/component/ConversationComposeBar.kt b/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationComposeBar.kt similarity index 79% rename from src/com/android/messaging/ui/conversation/v2/component/ConversationComposeBar.kt rename to src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationComposeBar.kt index 4546c910..32c9a379 100644 --- a/src/com/android/messaging/ui/conversation/v2/component/ConversationComposeBar.kt +++ b/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationComposeBar.kt @@ -39,9 +39,12 @@ private val CONVERSATION_COMPOSE_BAR_PREVIEW_PADDING_VERTICAL = 24.dp @Composable internal fun ConversationComposeBar( modifier: Modifier = Modifier, - value: String, - enabled: Boolean, - onValueChange: (String) -> Unit, + messageText: String, + isMessageFieldEnabled: Boolean, + isAttachmentActionEnabled: Boolean, + isSendActionEnabled: Boolean, + onAttachmentClick: () -> Unit, + onMessageTextChange: (String) -> Unit, onSendClick: () -> Unit, ) { val presentation = rememberConversationComposeBarPresentation() @@ -50,10 +53,13 @@ internal fun ConversationComposeBar( modifier = modifier, ) { ConversationComposeTextField( - value = value, - enabled = enabled, + messageText = messageText, + isMessageFieldEnabled = isMessageFieldEnabled, + isAttachmentActionEnabled = isAttachmentActionEnabled, + isSendActionEnabled = isSendActionEnabled, presentation = presentation, - onValueChange = onValueChange, + onAttachmentClick = onAttachmentClick, + onMessageTextChange = onMessageTextChange, onSendClick = onSendClick, ) } @@ -117,11 +123,14 @@ private fun ConversationComposeBarContainer( @Composable private fun ConversationComposeTextField( - value: String, - enabled: Boolean, + messageText: String, + isMessageFieldEnabled: Boolean, + isAttachmentActionEnabled: Boolean, + isSendActionEnabled: Boolean, presentation: ConversationComposeBarPresentation, - onValueChange: (String) -> Unit, - onSendClick: () -> Unit, + onAttachmentClick: (() -> Unit)?, + onMessageTextChange: (String) -> Unit, + onSendClick: (() -> Unit)?, ) { TextField( modifier = Modifier @@ -130,9 +139,9 @@ private fun ConversationComposeTextField( horizontal = CONVERSATION_COMPOSE_BAR_TEXT_FIELD_PADDING_HORIZONTAL, vertical = CONVERSATION_COMPOSE_BAR_TEXT_FIELD_PADDING_VERTICAL, ), - value = value, - onValueChange = onValueChange, - enabled = enabled, + value = messageText, + onValueChange = onMessageTextChange, + enabled = isMessageFieldEnabled, shape = presentation.fieldShape, colors = presentation.fieldColors, placeholder = { @@ -140,13 +149,15 @@ private fun ConversationComposeTextField( }, leadingIcon = { ConversationComposeLeadingAction( - enabled = enabled, - onClick = onSendClick, + enabled = isAttachmentActionEnabled && onAttachmentClick != null, + onClick = onAttachmentClick, ) }, trailingIcon = { ConversationComposeTrailingActions( - enabled = enabled, + isAttachmentActionEnabled = isAttachmentActionEnabled, + isSendActionEnabled = isSendActionEnabled, + onAttachmentClick = onAttachmentClick, onSendClick = onSendClick, ) }, @@ -165,10 +176,12 @@ private fun ConversationComposePlaceholder() { @Composable private fun ConversationComposeLeadingAction( enabled: Boolean, - onClick: () -> Unit, + onClick: (() -> Unit)?, ) { IconButton( - onClick = onClick, + onClick = { + onClick?.invoke() + }, enabled = enabled, ) { Icon( @@ -180,20 +193,22 @@ private fun ConversationComposeLeadingAction( @Composable private fun ConversationComposeTrailingActions( - enabled: Boolean, - onSendClick: () -> Unit, + isAttachmentActionEnabled: Boolean, + isSendActionEnabled: Boolean, + onAttachmentClick: (() -> Unit)?, + onSendClick: (() -> Unit)?, ) { Row( horizontalArrangement = Arrangement.spacedBy(space = CONVERSATION_COMPOSE_BAR_TRAILING_ACTION_SPACING), verticalAlignment = Alignment.CenterVertically, ) { ConversationComposeImageAction( - enabled = enabled, - onClick = onSendClick, + enabled = isAttachmentActionEnabled && onAttachmentClick != null, + onClick = onAttachmentClick, ) ConversationComposeSendAction( - enabled = enabled, + enabled = isSendActionEnabled && onSendClick != null, onClick = onSendClick, ) } @@ -202,10 +217,12 @@ private fun ConversationComposeTrailingActions( @Composable private fun ConversationComposeImageAction( enabled: Boolean, - onClick: () -> Unit, + onClick: (() -> Unit)?, ) { IconButton( - onClick = onClick, + onClick = { + onClick?.invoke() + }, enabled = enabled, ) { Icon( @@ -218,10 +235,12 @@ private fun ConversationComposeImageAction( @Composable private fun ConversationComposeSendAction( enabled: Boolean, - onClick: () -> Unit, + onClick: (() -> Unit)?, ) { IconButton( - onClick = onClick, + onClick = { + onClick?.invoke() + }, enabled = enabled, ) { Icon( diff --git a/src/com/android/messaging/ui/conversation/v2/mapper/ConversationMessageUiModelMapper.kt b/src/com/android/messaging/ui/conversation/v2/messages/mapper/ConversationMessageUiModelMapper.kt similarity index 100% rename from src/com/android/messaging/ui/conversation/v2/mapper/ConversationMessageUiModelMapper.kt rename to src/com/android/messaging/ui/conversation/v2/messages/mapper/ConversationMessageUiModelMapper.kt diff --git a/src/com/android/messaging/ui/conversation/v2/model/ConversationMessagePartUiModel.kt b/src/com/android/messaging/ui/conversation/v2/messages/model/ConversationMessagePartUiModel.kt similarity index 100% rename from src/com/android/messaging/ui/conversation/v2/model/ConversationMessagePartUiModel.kt rename to src/com/android/messaging/ui/conversation/v2/messages/model/ConversationMessagePartUiModel.kt diff --git a/src/com/android/messaging/ui/conversation/v2/model/ConversationMessageUiModel.kt b/src/com/android/messaging/ui/conversation/v2/messages/model/ConversationMessageUiModel.kt similarity index 100% rename from src/com/android/messaging/ui/conversation/v2/model/ConversationMessageUiModel.kt rename to src/com/android/messaging/ui/conversation/v2/messages/model/ConversationMessageUiModel.kt diff --git a/src/com/android/messaging/ui/conversation/v2/model/ConversationMessagesUiState.kt b/src/com/android/messaging/ui/conversation/v2/messages/model/ConversationMessagesUiState.kt similarity index 100% rename from src/com/android/messaging/ui/conversation/v2/model/ConversationMessagesUiState.kt rename to src/com/android/messaging/ui/conversation/v2/messages/model/ConversationMessagesUiState.kt diff --git a/src/com/android/messaging/ui/conversation/v2/component/ConversationMessage.kt b/src/com/android/messaging/ui/conversation/v2/messages/ui/ConversationMessage.kt similarity index 100% rename from src/com/android/messaging/ui/conversation/v2/component/ConversationMessage.kt rename to src/com/android/messaging/ui/conversation/v2/messages/ui/ConversationMessage.kt diff --git a/src/com/android/messaging/ui/conversation/v2/component/util/ConversationMessageDisplay.kt b/src/com/android/messaging/ui/conversation/v2/messages/ui/ConversationMessageDisplay.kt similarity index 100% rename from src/com/android/messaging/ui/conversation/v2/component/util/ConversationMessageDisplay.kt rename to src/com/android/messaging/ui/conversation/v2/messages/ui/ConversationMessageDisplay.kt diff --git a/src/com/android/messaging/ui/conversation/v2/component/ConversationMessages.kt b/src/com/android/messaging/ui/conversation/v2/messages/ui/ConversationMessages.kt similarity index 88% rename from src/com/android/messaging/ui/conversation/v2/component/ConversationMessages.kt rename to src/com/android/messaging/ui/conversation/v2/messages/ui/ConversationMessages.kt index 04a4f9de..d77655c2 100644 --- a/src/com/android/messaging/ui/conversation/v2/component/ConversationMessages.kt +++ b/src/com/android/messaging/ui/conversation/v2/messages/ui/ConversationMessages.kt @@ -58,33 +58,36 @@ internal fun ConversationMessages( listState: LazyListState, ) { val configuration = LocalConfiguration.current + val displayMessages = remember(messages) { + messages.asReversed() + } val timeZone = remember(configuration) { TimeZone.getDefault() } LazyColumn( state = listState, + reverseLayout = true, modifier = modifier .fillMaxSize() .background(color = MaterialTheme.colorScheme.background), contentPadding = CONVERSATION_MESSAGES_CONTENT_PADDING, ) { itemsIndexed( - items = messages, + items = displayMessages, key = { _, message -> message.messageId }, contentType = { index, _ -> conversationMessagesItemContentType( - messages = messages, + messages = displayMessages, index = index, timeZone = timeZone, ) }, ) { index, message -> ConversationMessagesItem( - index = index, message = message, - previousMessage = previousMessage( - messages = messages, + messageAbove = messageAboveCurrent( + messages = displayMessages, index = index, ), ) @@ -106,7 +109,7 @@ private fun conversationMessagesItemContentType( ): ConversationMessagesItemContentType { val shouldShowDateSeparator = shouldShowDateSeparator( currentMessage = messages[index], - previousMessage = previousMessage( + messageAbove = messageAboveCurrent( messages = messages, index = index, ), @@ -119,26 +122,21 @@ private fun conversationMessagesItemContentType( } } -private fun previousMessage( +private fun messageAboveCurrent( messages: List, index: Int, ): ConversationMessageUiModel? { - return when { - index > 0 -> messages[index - 1] - else -> null - } + return messages.getOrNull(index + 1) } @Composable private fun ConversationMessagesItem( - index: Int, message: ConversationMessageUiModel, - previousMessage: ConversationMessageUiModel?, + messageAbove: ConversationMessageUiModel?, ) { val presentation = rememberConversationMessagesItemPresentation( - index = index, message = message, - previousMessage = previousMessage, + messageAbove = messageAbove, ) ColumnWithSeparator( @@ -154,9 +152,8 @@ private fun ConversationMessagesItem( @Composable private fun rememberConversationMessagesItemPresentation( - index: Int, message: ConversationMessageUiModel, - previousMessage: ConversationMessageUiModel?, + messageAbove: ConversationMessageUiModel?, ): ConversationMessagesItemPresentation { val context = LocalContext.current val configuration = LocalConfiguration.current @@ -167,11 +164,11 @@ private fun rememberConversationMessagesItemPresentation( val showDateSeparator = remember( timeZone, message.displayTimestamp, - previousMessage?.displayTimestamp, + messageAbove?.displayTimestamp, ) { shouldShowDateSeparator( currentMessage = message, - previousMessage = previousMessage, + messageAbove = messageAbove, timeZone = timeZone, ) } @@ -193,13 +190,13 @@ private fun rememberConversationMessagesItemPresentation( } val topPadding = remember( - index, showDateSeparator, + messageAbove, message.canClusterWithPrevious, ) { messageItemTopPadding( - index = index, message = message, + messageAbove = messageAbove, showDateSeparator = showDateSeparator, ) } @@ -218,12 +215,12 @@ private fun rememberConversationMessagesItemPresentation( } private fun messageItemTopPadding( - index: Int, message: ConversationMessageUiModel, + messageAbove: ConversationMessageUiModel?, showDateSeparator: Boolean, ): Dp { return when { - index == 0 || showDateSeparator -> 0.dp + messageAbove == null || showDateSeparator -> 0.dp message.canClusterWithPrevious -> CONVERSATION_MESSAGES_CLUSTER_TOP_PADDING else -> CONVERSATION_MESSAGES_GROUP_TOP_PADDING } @@ -272,10 +269,10 @@ private fun ConversationDateSeparator( private fun shouldShowDateSeparator( currentMessage: ConversationMessageUiModel, - previousMessage: ConversationMessageUiModel?, + messageAbove: ConversationMessageUiModel?, timeZone: TimeZone, ): Boolean { - if (previousMessage == null) { + if (messageAbove == null) { return true } @@ -283,12 +280,12 @@ private fun shouldShowDateSeparator( displayTimestamp = currentMessage.displayTimestamp, timeZone = timeZone, ) ?: return false - val previousEpochDay = conversationMessageDisplayEpochDay( - displayTimestamp = previousMessage.displayTimestamp, + val messageAboveEpochDay = conversationMessageDisplayEpochDay( + displayTimestamp = messageAbove.displayTimestamp, timeZone = timeZone, ) - return previousEpochDay != currentEpochDay + return messageAboveEpochDay != currentEpochDay } private fun formatDateSeparatorText( diff --git a/src/com/android/messaging/ui/conversation/v2/mapper/ConversationMetadataUiStateMapper.kt b/src/com/android/messaging/ui/conversation/v2/metadata/mapper/ConversationMetadataUiStateMapper.kt similarity index 84% rename from src/com/android/messaging/ui/conversation/v2/mapper/ConversationMetadataUiStateMapper.kt rename to src/com/android/messaging/ui/conversation/v2/metadata/mapper/ConversationMetadataUiStateMapper.kt index 68385b86..3d05c220 100644 --- a/src/com/android/messaging/ui/conversation/v2/mapper/ConversationMetadataUiStateMapper.kt +++ b/src/com/android/messaging/ui/conversation/v2/metadata/mapper/ConversationMetadataUiStateMapper.kt @@ -1,6 +1,6 @@ package com.android.messaging.ui.conversation.v2.metadata.mapper -import com.android.messaging.data.conversation.repository.ConversationMetadata +import com.android.messaging.data.conversation.model.metadata.ConversationMetadata import com.android.messaging.ui.conversation.v2.metadata.model.ConversationMetadataUiState import javax.inject.Inject @@ -16,6 +16,7 @@ internal class ConversationMetadataUiStateMapperImpl @Inject constructor() : Con selfParticipantId = metadata.selfParticipantId, isGroupConversation = metadata.isGroupConversation, participantCount = metadata.participantCount, + composerAvailability = metadata.composerAvailability, ) } } diff --git a/src/com/android/messaging/ui/conversation/v2/metadata/model/ConversationMetadataUiState.kt b/src/com/android/messaging/ui/conversation/v2/metadata/model/ConversationMetadataUiState.kt new file mode 100644 index 00000000..be116c53 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/v2/metadata/model/ConversationMetadataUiState.kt @@ -0,0 +1,33 @@ +package com.android.messaging.ui.conversation.v2.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 + +@Immutable +internal sealed interface ConversationMetadataUiState { + val composerAvailability: ConversationComposerAvailability + + @Immutable + data object Loading : ConversationMetadataUiState { + override val composerAvailability = ConversationComposerAvailability.unavailable( + reason = ConversationComposerDisabledReason.CONVERSATION_UNAVAILABLE, + ) + } + + @Immutable + data class Present( + val title: String, + val selfParticipantId: String, + val isGroupConversation: Boolean, + val participantCount: Int, + override val composerAvailability: ConversationComposerAvailability, + ) : ConversationMetadataUiState + + @Immutable + data object Unavailable : ConversationMetadataUiState { + override val composerAvailability = ConversationComposerAvailability.unavailable( + reason = ConversationComposerDisabledReason.CONVERSATION_UNAVAILABLE, + ) + } +} diff --git a/src/com/android/messaging/ui/conversation/v2/component/ConversationTopAppBar.kt b/src/com/android/messaging/ui/conversation/v2/metadata/ui/ConversationTopAppBar.kt similarity index 97% rename from src/com/android/messaging/ui/conversation/v2/component/ConversationTopAppBar.kt rename to src/com/android/messaging/ui/conversation/v2/metadata/ui/ConversationTopAppBar.kt index 05c69a93..eec389ee 100644 --- a/src/com/android/messaging/ui/conversation/v2/component/ConversationTopAppBar.kt +++ b/src/com/android/messaging/ui/conversation/v2/metadata/ui/ConversationTopAppBar.kt @@ -195,6 +195,7 @@ private fun conversationTitle( ): String { return when (metadata) { ConversationMetadataUiState.Loading -> stringResource(id = R.string.app_name) + ConversationMetadataUiState.Unavailable -> stringResource(id = R.string.app_name) is ConversationMetadataUiState.Present -> { metadata @@ -210,6 +211,7 @@ private fun conversationIsGroup( ): Boolean { return when (metadata) { ConversationMetadataUiState.Loading -> false + ConversationMetadataUiState.Unavailable -> false is ConversationMetadataUiState.Present -> metadata.isGroupConversation } } @@ -220,6 +222,7 @@ private fun conversationSubtitle( ): String? { return when (metadata) { ConversationMetadataUiState.Loading -> stringResource(id = R.string.loading_messages) + ConversationMetadataUiState.Unavailable -> null is ConversationMetadataUiState.Present -> { when { diff --git a/src/com/android/messaging/ui/conversation/v2/model/ConversationMetadataUiState.kt b/src/com/android/messaging/ui/conversation/v2/model/ConversationMetadataUiState.kt deleted file mode 100644 index 9676efc5..00000000 --- a/src/com/android/messaging/ui/conversation/v2/model/ConversationMetadataUiState.kt +++ /dev/null @@ -1,18 +0,0 @@ -package com.android.messaging.ui.conversation.v2.metadata.model - -import androidx.compose.runtime.Immutable - -@Immutable -internal sealed interface ConversationMetadataUiState { - - @Immutable - data object Loading : ConversationMetadataUiState - - @Immutable - data class Present( - val title: String = "", - val selfParticipantId: String = "", - val isGroupConversation: Boolean = false, - val participantCount: Int = 0, - ) : ConversationMetadataUiState -} diff --git a/src/com/android/messaging/ui/conversation/v2/ConversationScreen.kt b/src/com/android/messaging/ui/conversation/v2/screen/ConversationScreen.kt similarity index 76% rename from src/com/android/messaging/ui/conversation/v2/ConversationScreen.kt rename to src/com/android/messaging/ui/conversation/v2/screen/ConversationScreen.kt index 1415ebf5..212a1598 100644 --- a/src/com/android/messaging/ui/conversation/v2/ConversationScreen.kt +++ b/src/com/android/messaging/ui/conversation/v2/screen/ConversationScreen.kt @@ -12,6 +12,8 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LifecycleEventEffect import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import com.android.messaging.ui.conversation.v2.composer.ui.ConversationComposeBar @@ -27,7 +29,11 @@ internal fun ConversationScreen( viewModel: ConversationViewModel = viewModel(), ) { LaunchedEffect(conversationId) { - viewModel.conversationId = conversationId + viewModel.onConversationChanged(conversationId = conversationId) + } + + LifecycleEventEffect(event = Lifecycle.Event.ON_STOP) { + viewModel.persistDraft() } val uiState by viewModel.uiState.collectAsStateWithLifecycle() @@ -42,15 +48,20 @@ internal fun ConversationScreen( }, bottomBar = { ConversationComposeBar( - value = "", - enabled = false, - onValueChange = {}, - onSendClick = {}, + messageText = uiState.composer.messageText, + isMessageFieldEnabled = uiState.composer.isMessageFieldEnabled, + isAttachmentActionEnabled = uiState.composer.isAttachmentActionEnabled, + isSendActionEnabled = uiState.composer.isSendEnabled, + onAttachmentClick = viewModel::onAttachmentClick, + onMessageTextChange = { messageText -> + viewModel.onMessageTextChanged(text = messageText) + }, + onSendClick = viewModel::onSendClick, ) }, ) { contentPadding -> ConversationScreenContent( - modifier = Modifier.padding(contentPadding), + modifier = Modifier.padding(paddingValues = contentPadding), conversationId = conversationId, uiState = uiState, ) @@ -76,7 +87,6 @@ private fun ConversationScreenContent( is ConversationMessagesUiState.Present -> { val messagesListState = rememberMessagesListState( conversationId = conversationId, - initialMessageIndex = messagesState.messages.lastIndex.coerceAtLeast(minimumValue = 0), ) ConversationMessages( @@ -91,14 +101,13 @@ private fun ConversationScreenContent( @Composable private fun rememberMessagesListState( conversationId: String?, - initialMessageIndex: Int, ): LazyListState { return rememberSaveable( conversationId, saver = LazyListState.Saver, ) { LazyListState( - firstVisibleItemIndex = initialMessageIndex, + firstVisibleItemIndex = 0, firstVisibleItemScrollOffset = 0, ) } diff --git a/src/com/android/messaging/ui/conversation/v2/model/ConversationUiState.kt b/src/com/android/messaging/ui/conversation/v2/screen/ConversationUiState.kt similarity index 74% rename from src/com/android/messaging/ui/conversation/v2/model/ConversationUiState.kt rename to src/com/android/messaging/ui/conversation/v2/screen/ConversationUiState.kt index 5c3acc54..cf6826bd 100644 --- a/src/com/android/messaging/ui/conversation/v2/model/ConversationUiState.kt +++ b/src/com/android/messaging/ui/conversation/v2/screen/ConversationUiState.kt @@ -1,6 +1,7 @@ package com.android.messaging.ui.conversation.v2.screen import androidx.compose.runtime.Immutable +import com.android.messaging.ui.conversation.v2.composer.model.ConversationComposerUiState import com.android.messaging.ui.conversation.v2.messages.model.ConversationMessagesUiState import com.android.messaging.ui.conversation.v2.metadata.model.ConversationMetadataUiState @@ -8,4 +9,5 @@ import com.android.messaging.ui.conversation.v2.metadata.model.ConversationMetad internal data class ConversationUiState( val metadata: ConversationMetadataUiState = ConversationMetadataUiState.Loading, val messages: ConversationMessagesUiState = ConversationMessagesUiState.Loading, + val composer: ConversationComposerUiState = ConversationComposerUiState(), ) From df6bfa300815844defb2c61b6cb1123b767ed5c4 Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Sat, 28 Mar 2026 02:52:28 +0200 Subject: [PATCH 09/99] Composer draft send flow --- .../ConversationDraftMessageDataMapper.kt | 91 ++++ .../repository/ConversationDraftStore.kt | 62 +++ .../ConversationDraftsRepository.kt | 167 ++---- .../ConversationMetadataNotifier.kt | 16 + .../conversation/ConversationBindsModule.kt | 32 ++ .../usecase/SendConversationDraft.kt | 87 +++ .../delegate/ConversationDraftDelegate.kt | 510 ++++++++++++++++-- .../delegate/ConversationDraftEffect.kt | 7 + .../ConversationComposerUiStateMapper.kt | 10 +- .../v2/screen/ConversationViewModel.kt | 36 +- .../screen/model/ConversationScreenEffect.kt | 7 + .../screen/{ => model}/ConversationUiState.kt | 2 +- .../core/extension/KotlinFlowExtensions.kt | 11 +- 13 files changed, 860 insertions(+), 178 deletions(-) create mode 100644 src/com/android/messaging/data/conversation/mapper/ConversationDraftMessageDataMapper.kt create mode 100644 src/com/android/messaging/data/conversation/repository/ConversationDraftStore.kt create mode 100644 src/com/android/messaging/data/conversation/repository/ConversationMetadataNotifier.kt create mode 100644 src/com/android/messaging/domain/conversation/usecase/SendConversationDraft.kt create mode 100644 src/com/android/messaging/ui/conversation/v2/composer/delegate/ConversationDraftEffect.kt create mode 100644 src/com/android/messaging/ui/conversation/v2/screen/model/ConversationScreenEffect.kt rename src/com/android/messaging/ui/conversation/v2/screen/{ => model}/ConversationUiState.kt (90%) diff --git a/src/com/android/messaging/data/conversation/mapper/ConversationDraftMessageDataMapper.kt b/src/com/android/messaging/data/conversation/mapper/ConversationDraftMessageDataMapper.kt new file mode 100644 index 00000000..8e452a55 --- /dev/null +++ b/src/com/android/messaging/data/conversation/mapper/ConversationDraftMessageDataMapper.kt @@ -0,0 +1,91 @@ +package com.android.messaging.data.conversation.mapper + +import androidx.core.net.toUri +import com.android.messaging.data.conversation.model.draft.ConversationDraft +import com.android.messaging.data.conversation.model.draft.ConversationDraftAttachment +import com.android.messaging.datamodel.data.MessageData +import com.android.messaging.datamodel.data.MessagePartData +import com.android.messaging.util.LogUtil +import javax.inject.Inject + +internal interface ConversationDraftMessageDataMapper { + fun map( + conversationId: String, + draft: ConversationDraft, + ): MessageData +} + +internal class ConversationDraftMessageDataMapperImpl @Inject constructor() : + ConversationDraftMessageDataMapper { + + override fun map( + conversationId: String, + draft: ConversationDraft, + ): MessageData { + val selfParticipantId = draft.selfParticipantId.takeIf { it.isNotBlank() } + val messageParts = draft.attachments.mapNotNull(::createMessagePartDataOrNull) + val isMms = draft.subjectText.isNotBlank() || messageParts.isNotEmpty() + + val message = when { + isMms -> MessageData.createDraftMmsMessage( + conversationId, + selfParticipantId, + draft.messageText, + draft.subjectText, + ) + + else -> MessageData.createDraftSmsMessage( + conversationId, + selfParticipantId, + draft.messageText, + ) + } + + messageParts.forEach(message::addPart) + + return message + } + + private fun createMessagePartDataOrNull( + attachment: ConversationDraftAttachment, + ): MessagePartData? { + if (attachment.contentType.isBlank() || attachment.contentUri.isBlank()) { + LogUtil.w(TAG, "Dropping draft attachment with blank contentType or contentUri") + return null + } + + val captionText = attachment.captionText.takeIf { it.isNotBlank() } + val contentUri = attachment.contentUri.toUri() + val width = toLegacyPartDimension(size = attachment.width) + val height = toLegacyPartDimension(size = attachment.height) + + return when { + captionText != null -> { + MessagePartData.createMediaMessagePart( + captionText, + attachment.contentType, + contentUri, + width, + height, + ) + } + + else -> { + MessagePartData.createMediaMessagePart( + attachment.contentType, + contentUri, + width, + height, + ) + } + } + } + + private fun toLegacyPartDimension(size: Int?): Int { + return size ?: MessagePartData.UNSPECIFIED_SIZE + } + + private companion object { + private const val TAG = "ConversationDraftMessageDataMapper" + } +} diff --git a/src/com/android/messaging/data/conversation/repository/ConversationDraftStore.kt b/src/com/android/messaging/data/conversation/repository/ConversationDraftStore.kt new file mode 100644 index 00000000..3bdd4313 --- /dev/null +++ b/src/com/android/messaging/data/conversation/repository/ConversationDraftStore.kt @@ -0,0 +1,62 @@ +package com.android.messaging.data.conversation.repository + +import com.android.messaging.datamodel.BugleDatabaseOperations +import com.android.messaging.datamodel.DataModel +import com.android.messaging.datamodel.data.ConversationListItemData +import com.android.messaging.datamodel.data.MessageData +import javax.inject.Inject + +internal data class ConversationDraftConversation( + val selfParticipantId: String, +) + +internal interface ConversationDraftStore { + fun getConversation(conversationId: String): ConversationDraftConversation? + + fun readDraftMessage( + conversationId: String, + selfParticipantId: String, + ): MessageData? + + fun updateDraftMessage( + conversationId: String, + message: MessageData, + ) +} + +internal class ConversationDraftStoreImpl @Inject constructor() : ConversationDraftStore { + + override fun getConversation(conversationId: String): ConversationDraftConversation? { + val conversation = ConversationListItemData.getExistingConversation( + DataModel.get().database, + conversationId, + ) ?: return null + + return ConversationDraftConversation( + selfParticipantId = conversation.selfId.orEmpty(), + ) + } + + override fun readDraftMessage( + conversationId: String, + selfParticipantId: String, + ): MessageData? { + return BugleDatabaseOperations.readDraftMessageData( + DataModel.get().database, + conversationId, + selfParticipantId, + ) + } + + override fun updateDraftMessage( + conversationId: String, + message: MessageData, + ) { + BugleDatabaseOperations.updateDraftMessageData( + DataModel.get().database, + conversationId, + message, + BugleDatabaseOperations.UPDATE_MODE_ADD_DRAFT, + ) + } +} diff --git a/src/com/android/messaging/data/conversation/repository/ConversationDraftsRepository.kt b/src/com/android/messaging/data/conversation/repository/ConversationDraftsRepository.kt index dcfe4a2a..dacf63ec 100644 --- a/src/com/android/messaging/data/conversation/repository/ConversationDraftsRepository.kt +++ b/src/com/android/messaging/data/conversation/repository/ConversationDraftsRepository.kt @@ -3,26 +3,24 @@ package com.android.messaging.data.conversation.repository import android.content.ContentResolver import android.database.ContentObserver import android.net.Uri -import androidx.core.net.toUri +import com.android.messaging.data.conversation.mapper.ConversationDraftMessageDataMapper import com.android.messaging.data.conversation.model.draft.ConversationDraft import com.android.messaging.data.conversation.model.draft.ConversationDraftAttachment -import com.android.messaging.datamodel.BugleDatabaseOperations -import com.android.messaging.datamodel.DataModel import com.android.messaging.datamodel.MessagingContentProvider -import com.android.messaging.datamodel.data.ConversationListItemData import com.android.messaging.datamodel.data.MessageData import com.android.messaging.datamodel.data.MessagePartData import com.android.messaging.di.core.IoDispatcher import com.android.messaging.util.LogUtil +import javax.inject.Inject import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.conflate import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map import kotlinx.coroutines.withContext -import javax.inject.Inject internal interface ConversationDraftsRepository { fun observeConversationDraft(conversationId: String): Flow @@ -35,6 +33,9 @@ internal interface ConversationDraftsRepository { internal class ConversationDraftsRepositoryImpl @Inject constructor( private val contentResolver: ContentResolver, + private val conversationDraftMessageDataMapper: ConversationDraftMessageDataMapper, + private val conversationDraftStore: ConversationDraftStore, + private val conversationMetadataNotifier: ConversationMetadataNotifier, @param:IoDispatcher private val ioDispatcher: CoroutineDispatcher, ) : ConversationDraftsRepository { @@ -45,6 +46,15 @@ internal class ConversationDraftsRepositoryImpl @Inject constructor( return observeDraftChanges(uri = draftChangeUri) .conflate() .map { loadConversationDraft(conversationId = conversationId) } + .catch { e -> + LogUtil.e( + TAG, + "Failed to load draft for conversation $conversationId", + e, + ) + + emit(ConversationDraft()) + } .flowOn(ioDispatcher) } @@ -53,7 +63,7 @@ internal class ConversationDraftsRepositoryImpl @Inject constructor( draft: ConversationDraft, ) { withContext(context = ioDispatcher) { - val message = createDraftMessage( + val message = conversationDraftMessageDataMapper.map( conversationId = conversationId, draft = draft, ) @@ -62,14 +72,14 @@ internal class ConversationDraftsRepositoryImpl @Inject constructor( message = message, ) ?: return@withContext - BugleDatabaseOperations.updateDraftMessageData( - DataModel.get().database, - conversationId, - boundMessage, - BugleDatabaseOperations.UPDATE_MODE_ADD_DRAFT, + conversationDraftStore.updateDraftMessage( + conversationId = conversationId, + message = boundMessage, ) - MessagingContentProvider.notifyConversationMetadataChanged(conversationId) + conversationMetadataNotifier.notifyConversationMetadataChanged( + conversationId = conversationId, + ) } } @@ -91,15 +101,13 @@ internal class ConversationDraftsRepositoryImpl @Inject constructor( } private fun loadConversationDraft(conversationId: String): ConversationDraft { - val database = DataModel.get().database - val conversation = ConversationListItemData - .getExistingConversation(database, conversationId) - ?: return ConversationDraft() + val conversation = conversationDraftStore.getConversation( + conversationId = conversationId, + ) ?: return ConversationDraft() - val draftMessage = BugleDatabaseOperations.readDraftMessageData( - database, - conversationId, - conversation.selfId, + val draftMessage = conversationDraftStore.readDraftMessage( + conversationId = conversationId, + selfParticipantId = conversation.selfParticipantId, ) return createConversationDraft( @@ -109,7 +117,7 @@ internal class ConversationDraftsRepositoryImpl @Inject constructor( } private fun createConversationDraft( - conversation: ConversationListItemData, + conversation: ConversationDraftConversation, draftMessage: MessageData?, ): ConversationDraft { val attachments = draftMessage @@ -123,7 +131,7 @@ internal class ConversationDraftsRepositoryImpl @Inject constructor( val selfParticipantId = draftMessage ?.selfId ?.takeIf { selfParticipantId -> selfParticipantId.isNotBlank() } - ?: conversation.selfId.orEmpty() + ?: conversation.selfParticipantId return ConversationDraft( messageText = draftMessage?.messageText.orEmpty(), @@ -133,37 +141,6 @@ internal class ConversationDraftsRepositoryImpl @Inject constructor( ) } - private fun createDraftMessage( - conversationId: String, - draft: ConversationDraft, - ): MessageData { - val selfParticipantId = draft.selfParticipantId.takeIf { selfParticipantId -> - selfParticipantId.isNotBlank() - } - val messageParts = draft.attachments.mapNotNull(::createMessagePartDataOrNull) - - val isMms = draft.subjectText.isNotBlank() || messageParts.isNotEmpty() - - val message = when { - isMms -> MessageData.createDraftMmsMessage( - conversationId, - selfParticipantId, - draft.messageText, - draft.subjectText, - ) - - else -> MessageData.createDraftSmsMessage( - conversationId, - selfParticipantId, - draft.messageText, - ) - } - - messageParts.forEach(message::addPart) - - return message - } - private fun bindDraftParticipantsIfNeeded( conversationId: String, message: MessageData, @@ -172,9 +149,8 @@ internal class ConversationDraftsRepositoryImpl @Inject constructor( return message } - val conversation = ConversationListItemData.getExistingConversation( - DataModel.get().database, - conversationId, + val conversation = conversationDraftStore.getConversation( + conversationId = conversationId, ) ?: run { LogUtil.w( TAG, @@ -183,10 +159,11 @@ internal class ConversationDraftsRepositoryImpl @Inject constructor( return null } - val selfParticipantId = conversation.selfId + val selfParticipantId = conversation.selfParticipantId if (message.selfId == null) { message.bindSelfId(selfParticipantId) } + if (message.participantId == null) { message.bindParticipantId(selfParticipantId) } @@ -195,76 +172,32 @@ internal class ConversationDraftsRepositoryImpl @Inject constructor( } private fun createDraftAttachmentOrNull(part: MessagePartData): ConversationDraftAttachment? { - val contentType = part - .contentType - ?.takeIf { value -> value.isNotBlank() } - ?: run { - LogUtil.w(TAG, "Dropping draft attachment with blank contentType") - return null + val contentType = part.contentType?.takeIf { it.isNotBlank() } + val contentUri = part.contentUri?.toString()?.takeIf { it.isNotBlank() } + + return when { + contentType != null && contentUri != null -> { + ConversationDraftAttachment( + contentType = contentType, + contentUri = contentUri, + captionText = part.text.orEmpty(), + width = normalizePartDimension(size = part.width), + height = normalizePartDimension(size = part.height), + ) } - val contentUri = part - .contentUri - ?.toString() - ?.takeIf { value -> value.isNotBlank() } - ?: run { - LogUtil.w(TAG, "Dropping draft attachment with blank contentUri") - return null - } - - return ConversationDraftAttachment( - contentType = contentType, - contentUri = contentUri, - captionText = part.text.orEmpty(), - width = normalizePartDimension(size = part.width), - height = normalizePartDimension(size = part.height), - ) - } - - private fun createMessagePartDataOrNull( - attachment: ConversationDraftAttachment, - ): MessagePartData? { - if (attachment.contentType.isBlank()) { - LogUtil.w(TAG, "Dropping draft attachment with blank contentType during save") - return null - } + else -> { + LogUtil.w(TAG, "Dropping draft attachment with blank contentType or contentUri") - if (attachment.contentUri.isBlank()) { - LogUtil.w(TAG, "Dropping draft attachment with blank contentUri during save") - return null - } - - val captionText = attachment.captionText.takeIf { value -> value.isNotBlank() } - val contentUri = attachment.contentUri.toUri() - val width = toLegacyPartDimension(size = attachment.width) - val height = toLegacyPartDimension(size = attachment.height) - - captionText?.let { nonBlankCaptionText -> - return MessagePartData.createMediaMessagePart( - nonBlankCaptionText, - attachment.contentType, - contentUri, - width, - height, - ) + null + } } - - return MessagePartData.createMediaMessagePart( - attachment.contentType, - contentUri, - width, - height, - ) } private fun normalizePartDimension(size: Int): Int? { return size.takeIf { it != MessagePartData.UNSPECIFIED_SIZE } } - private fun toLegacyPartDimension(size: Int?): Int { - return size ?: MessagePartData.UNSPECIFIED_SIZE - } - private companion object { private const val TAG = "ConversationDraftsRepository" } diff --git a/src/com/android/messaging/data/conversation/repository/ConversationMetadataNotifier.kt b/src/com/android/messaging/data/conversation/repository/ConversationMetadataNotifier.kt new file mode 100644 index 00000000..9c1dd658 --- /dev/null +++ b/src/com/android/messaging/data/conversation/repository/ConversationMetadataNotifier.kt @@ -0,0 +1,16 @@ +package com.android.messaging.data.conversation.repository + +import com.android.messaging.datamodel.MessagingContentProvider +import javax.inject.Inject + +internal interface ConversationMetadataNotifier { + fun notifyConversationMetadataChanged(conversationId: String) +} + +internal class ConversationMetadataNotifierImpl @Inject constructor() : + ConversationMetadataNotifier { + + override fun notifyConversationMetadataChanged(conversationId: String) { + MessagingContentProvider.notifyConversationMetadataChanged(conversationId) + } +} diff --git a/src/com/android/messaging/di/conversation/ConversationBindsModule.kt b/src/com/android/messaging/di/conversation/ConversationBindsModule.kt index 7d71ce02..8d0698a3 100644 --- a/src/com/android/messaging/di/conversation/ConversationBindsModule.kt +++ b/src/com/android/messaging/di/conversation/ConversationBindsModule.kt @@ -1,9 +1,17 @@ package com.android.messaging.di.conversation +import com.android.messaging.data.conversation.mapper.ConversationDraftMessageDataMapper +import com.android.messaging.data.conversation.mapper.ConversationDraftMessageDataMapperImpl +import com.android.messaging.data.conversation.repository.ConversationDraftStore +import com.android.messaging.data.conversation.repository.ConversationDraftStoreImpl import com.android.messaging.data.conversation.repository.ConversationDraftsRepository import com.android.messaging.data.conversation.repository.ConversationDraftsRepositoryImpl +import com.android.messaging.data.conversation.repository.ConversationMetadataNotifier +import com.android.messaging.data.conversation.repository.ConversationMetadataNotifierImpl import com.android.messaging.data.conversation.repository.ConversationsRepository import com.android.messaging.data.conversation.repository.ConversationsRepositoryImpl +import com.android.messaging.domain.conversation.usecase.SendConversationDraft +import com.android.messaging.domain.conversation.usecase.SendConversationDraftImpl import com.android.messaging.ui.conversation.v2.composer.delegate.ConversationDraftDelegate import com.android.messaging.ui.conversation.v2.composer.delegate.ConversationDraftDelegateImpl import com.android.messaging.ui.conversation.v2.composer.mapper.ConversationComposerUiStateMapper @@ -26,6 +34,24 @@ import dagger.hilt.components.SingletonComponent @InstallIn(SingletonComponent::class) internal abstract class ConversationBindsModule { + @Binds + @Reusable + abstract fun bindConversationDraftMessageDataMapper( + impl: ConversationDraftMessageDataMapperImpl, + ): ConversationDraftMessageDataMapper + + @Binds + @Reusable + abstract fun bindConversationDraftStore( + impl: ConversationDraftStoreImpl, + ): ConversationDraftStore + + @Binds + @Reusable + abstract fun bindConversationMetadataNotifier( + impl: ConversationMetadataNotifierImpl, + ): ConversationMetadataNotifier + @Binds @Reusable abstract fun bindConversationDraftsRepository( @@ -67,4 +93,10 @@ internal abstract class ConversationBindsModule { abstract fun bindConversationMetadataUiStateMapper( impl: ConversationMetadataUiStateMapperImpl, ): ConversationMetadataUiStateMapper + + @Binds + @Reusable + abstract fun bindSendConversationDraft( + impl: SendConversationDraftImpl, + ): SendConversationDraft } diff --git a/src/com/android/messaging/domain/conversation/usecase/SendConversationDraft.kt b/src/com/android/messaging/domain/conversation/usecase/SendConversationDraft.kt new file mode 100644 index 00000000..2057627c --- /dev/null +++ b/src/com/android/messaging/domain/conversation/usecase/SendConversationDraft.kt @@ -0,0 +1,87 @@ +package com.android.messaging.domain.conversation.usecase + +import com.android.messaging.data.conversation.mapper.ConversationDraftMessageDataMapper +import com.android.messaging.data.conversation.model.draft.ConversationDraft +import com.android.messaging.datamodel.action.InsertNewMessageAction +import com.android.messaging.di.core.DefaultDispatcher +import com.android.messaging.util.core.extension.unitFlow +import javax.inject.Inject +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.withContext + +internal interface SendConversationDraft { + operator fun invoke( + conversationId: String, + draft: ConversationDraft, + ): Flow +} + +internal class SendConversationDraftImpl @Inject constructor( + private val conversationDraftMessageDataMapper: ConversationDraftMessageDataMapper, + @param:DefaultDispatcher + private val defaultDispatcher: CoroutineDispatcher, +) : SendConversationDraft { + + override operator fun invoke( + conversationId: String, + draft: ConversationDraft, + ): Flow { + if (conversationId.isBlank()) { + throw BlankConversationIdException() + } + + if (!draft.hasContent) { + throw EmptyConversationDraftException( + conversationId = conversationId, + ) + } + + return unitFlow { + try { + withContext(context = defaultDispatcher) { + val message = conversationDraftMessageDataMapper.map( + conversationId = conversationId, + draft = draft, + ) + + message.consolidateText() + InsertNewMessageAction.insertNewMessage(message) + } + } catch (exception: CancellationException) { + throw exception + } catch (exception: Exception) { + throw DraftDispatchFailedException( + conversationId = conversationId, + cause = exception, + ) + } + } + } +} + +internal sealed class SendConversationDraftException( + message: String, + cause: Throwable? = null, +) : Exception(message, cause) + +internal class BlankConversationIdException : + SendConversationDraftException( + message = "Conversation id must not be blank.", + ) + +internal class EmptyConversationDraftException( + conversationId: String, +) : SendConversationDraftException( + message = "Draft must contain content before it can be sent " + + "for conversation $conversationId.", +) + +internal class DraftDispatchFailedException( + conversationId: String, + cause: Throwable, +) : SendConversationDraftException( + message = "Failed to enqueue outgoing draft for conversation $conversationId.", + cause = cause, +) diff --git a/src/com/android/messaging/ui/conversation/v2/composer/delegate/ConversationDraftDelegate.kt b/src/com/android/messaging/ui/conversation/v2/composer/delegate/ConversationDraftDelegate.kt index 6c326038..ef01fe8c 100644 --- a/src/com/android/messaging/ui/conversation/v2/composer/delegate/ConversationDraftDelegate.kt +++ b/src/com/android/messaging/ui/conversation/v2/composer/delegate/ConversationDraftDelegate.kt @@ -5,32 +5,48 @@ import com.android.messaging.data.conversation.model.draft.ConversationDraftAtta import com.android.messaging.data.conversation.repository.ConversationDraftsRepository import com.android.messaging.di.core.ApplicationCoroutineScope import com.android.messaging.di.core.DefaultDispatcher +import com.android.messaging.domain.conversation.usecase.SendConversationDraft import com.android.messaging.ui.conversation.v2.common.ConversationScreenDelegate +import com.android.messaging.util.LogUtil +import com.android.messaging.util.core.extension.unitFlow +import javax.inject.Inject import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.emitAll import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onCompletion +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.transformLatest import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext -import javax.inject.Inject internal interface ConversationDraftDelegate : ConversationScreenDelegate { + val effects: Flow fun onMessageTextChanged(messageText: String) + fun onAttachmentClick() + + fun onSendClick() + fun persistDraft() fun flushDraft() @@ -41,11 +57,16 @@ internal class ConversationDraftDelegateImpl @Inject constructor( @param:ApplicationCoroutineScope private val applicationScope: CoroutineScope, private val conversationDraftsRepository: ConversationDraftsRepository, + private val sendConversationDraft: SendConversationDraft, @param:DefaultDispatcher private val defaultDispatcher: CoroutineDispatcher, ) : ConversationDraftDelegate { + private val _effects = MutableSharedFlow( + extraBufferCapacity = 1, + ) private val _state = MutableStateFlow(ConversationDraft()) + override val effects = _effects.asSharedFlow() override val state = _state.asStateFlow() private val draftEditorState = MutableStateFlow(DraftEditorState()) @@ -78,33 +99,49 @@ internal class ConversationDraftDelegateImpl @Inject constructor( } } - override fun persistDraft() { - val currentDraftEditorState = draftEditorState.value + override fun onAttachmentClick() { val scope = boundScope ?: return - scope.launch(start = CoroutineStart.UNDISPATCHED) { - val saveRequest = currentDraftEditorState.toSaveRequestOrNull() ?: return@launch + launchDraftOperation(scope = scope) { + createAttachmentClickFlow() + } + } - saveDraft( - saveRequest = saveRequest, - shouldMarkCurrentDraftAsPersisted = true, + override fun onSendClick() { + val scope = boundScope ?: return + val sendRequest = markSendingAndCreateSendRequestOrNull() ?: return + + launchDraftOperation(scope = scope) { + createSendDraftFlow( + sendRequest = sendRequest, ) } } - override fun flushDraft() { + override fun persistDraft() { + val scope = boundScope ?: return val saveRequest = draftEditorState.value.toSaveRequestOrNull() ?: return - applicationScope.launch { - flushDraft(saveRequest = saveRequest) + launchDraftOperation(scope = scope) { + createSaveDraftOperationFlow( + operationName = "persist draft", + saveRequest = saveRequest, + shouldMarkCurrentDraftAsPersisted = true, + shouldSkipIfRequestIsStale = true, + ) } } - private suspend fun flushDraft(saveRequest: DraftSaveRequest) { - withContext(context = NonCancellable) { - saveDraft( + override fun flushDraft() { + val saveRequest = draftEditorState.value.toSaveRequestOrNull() ?: return + + launchDraftOperation(scope = applicationScope) { + createSaveDraftOperationFlow( + operationName = "flush draft", saveRequest = saveRequest, shouldMarkCurrentDraftAsPersisted = false, + shouldSkipIfRequestIsStale = false, + shouldRunNonCancellable = true, ) } } @@ -112,8 +149,18 @@ internal class ConversationDraftDelegateImpl @Inject constructor( private suspend fun saveDraft( saveRequest: DraftSaveRequest, shouldMarkCurrentDraftAsPersisted: Boolean, + shouldSkipIfRequestIsStale: Boolean, ) { draftSaveMutex.withLock { + // Ignore debounced or queued saves that no longer reflect the current working draft + if (shouldSkipIfRequestIsStale && + !draftEditorState.value.matchesSaveRequest( + saveRequest = saveRequest, + ) + ) { + return@withLock + } + conversationDraftsRepository.saveDraft( conversationId = saveRequest.conversationId, draft = saveRequest.draft, @@ -136,33 +183,33 @@ internal class ConversationDraftDelegateImpl @Inject constructor( conversationIdFlow: StateFlow, ) { scope.launch(defaultDispatcher) { - conversationIdFlow.collectLatest { conversationId -> - resetDraftEditorState(conversationId = conversationId) - - if (conversationId == null) { - return@collectLatest + observeConversationDraftUpdates(conversationIdFlow = conversationIdFlow) + .collect { persistedDraftUpdate -> + updateDraftEditorState { currentDraftEditorState -> + if (currentDraftEditorState.conversationId != + persistedDraftUpdate.conversationId + ) { + currentDraftEditorState + } else { + currentDraftEditorState.withPersistedDraft( + persistedDraft = persistedDraftUpdate.persistedDraft, + ) + } + } } - - observePersistedDraft(conversationId = conversationId) - } } } private fun bindDraftAutosave(scope: CoroutineScope) { scope.launch(defaultDispatcher) { - draftEditorState - .map { currentDraftEditorState -> - currentDraftEditorState.toSaveRequestOrNull() - } - .distinctUntilChanged() - .debounce(timeoutMillis = DRAFT_AUTOSAVE_DELAY_MILLIS) - .filterNotNull() - .collect { saveRequest -> - saveDraft( - saveRequest = saveRequest, - shouldMarkCurrentDraftAsPersisted = true, - ) - } + observeDraftAutosaveRequests().collect { saveRequest -> + createSaveDraftOperationFlow( + operationName = "autosave draft", + saveRequest = saveRequest, + shouldMarkCurrentDraftAsPersisted = true, + shouldSkipIfRequestIsStale = true, + ).collect() + } } } @@ -177,24 +224,168 @@ internal class ConversationDraftDelegateImpl @Inject constructor( previousDraftEditorState .toSaveRequestOrNull() ?.let { saveRequest -> - flushDraft(saveRequest = saveRequest) + createSaveDraftOperationFlow( + operationName = "flush previous draft", + saveRequest = saveRequest, + shouldMarkCurrentDraftAsPersisted = false, + shouldSkipIfRequestIsStale = false, + shouldRunNonCancellable = true, + ).collect() } } - private suspend fun observePersistedDraft(conversationId: String) { - conversationDraftsRepository - .observeConversationDraft(conversationId = conversationId) - .collect { persistedDraft -> - updateDraftEditorState { currentDraftEditorState -> - if (currentDraftEditorState.conversationId != conversationId) { - return@updateDraftEditorState currentDraftEditorState - } + private fun launchDraftOperation( + scope: CoroutineScope, + createOperationFlow: () -> Flow, + ) { + scope.launch(defaultDispatcher) { + createOperationFlow().collect() + } + } - return@updateDraftEditorState currentDraftEditorState.withPersistedDraft( - persistedDraft = persistedDraft, + private fun createAttachmentClickFlow(): Flow { + return runDraftOperationBoundary( + operationName = "launch attachment chooser", + conversationId = draftEditorState.value.conversationId, + ) { + unitFlow { + val currentDraftEditorState = draftEditorState.value + if (!currentDraftEditorState.canLaunchAttachmentChooser()) { + return@unitFlow + } + + val saveRequest = currentDraftEditorState.toSaveRequestOrNull() + if (saveRequest != null) { + saveDraft( + saveRequest = saveRequest, + shouldMarkCurrentDraftAsPersisted = true, + shouldSkipIfRequestIsStale = true, ) } + + val conversationId = draftEditorState.value.conversationId ?: return@unitFlow + _effects.emit( + value = ConversationDraftEffect.LaunchAttachmentChooser( + conversationId = conversationId, + ), + ) } + } + } + + private fun createSendDraftFlow(sendRequest: DraftSendRequest): Flow { + var didClearDraftAfterSend = false + + return runDraftOperationBoundary( + operationName = "send draft", + conversationId = sendRequest.conversationId, + ) { + sendConversationDraft( + conversationId = sendRequest.conversationId, + draft = sendRequest.draft, + ).onEach { + clearConversationDraftAfterSend(sendRequest = sendRequest) + didClearDraftAfterSend = true + }.onCompletion { throwable -> + if (throwable != null || !didClearDraftAfterSend) { + markConversationDraftAsIdle(conversationId = sendRequest.conversationId) + } + } + } + } + + private fun createSaveDraftOperationFlow( + operationName: String, + saveRequest: DraftSaveRequest, + shouldMarkCurrentDraftAsPersisted: Boolean, + shouldSkipIfRequestIsStale: Boolean, + shouldRunNonCancellable: Boolean = false, + ): Flow { + return runDraftOperationBoundary( + operationName = operationName, + conversationId = saveRequest.conversationId, + ) { + unitFlow { + if (shouldRunNonCancellable) { + withContext(context = NonCancellable) { + saveDraft( + saveRequest = saveRequest, + shouldMarkCurrentDraftAsPersisted = shouldMarkCurrentDraftAsPersisted, + shouldSkipIfRequestIsStale = shouldSkipIfRequestIsStale, + ) + } + + return@unitFlow + } + + saveDraft( + saveRequest = saveRequest, + shouldMarkCurrentDraftAsPersisted = shouldMarkCurrentDraftAsPersisted, + shouldSkipIfRequestIsStale = shouldSkipIfRequestIsStale, + ) + } + } + } + + private fun observeConversationDraftUpdates( + conversationIdFlow: StateFlow, + ): Flow { + return runDraftOperationBoundary( + operationName = "observe drafts", + conversationId = null, + ) { + conversationIdFlow.transformLatest { conversationId -> + resetDraftEditorState(conversationId = conversationId) + + if (conversationId == null) { + return@transformLatest + } + + emitAll(createPersistedDraftUpdatesFlow(conversationId = conversationId)) + } + } + } + + private fun createPersistedDraftUpdatesFlow( + conversationId: String, + ): Flow { + return conversationDraftsRepository + .observeConversationDraft(conversationId = conversationId) + .map { persistedDraft -> + PersistedDraftUpdate( + conversationId = conversationId, + persistedDraft = persistedDraft, + ) + } + .catch { exception -> + LogUtil.e( + TAG, + "Failed to observe draft for conversation $conversationId", + exception, + ) + + emit( + PersistedDraftUpdate( + conversationId = conversationId, + persistedDraft = ConversationDraft(), + ), + ) + } + } + + private fun observeDraftAutosaveRequests(): Flow { + return runDraftOperationBoundary( + operationName = "bind draft autosave", + conversationId = null, + ) { + draftEditorState + .map { currentDraftEditorState -> + currentDraftEditorState.toSaveRequestOrNull() + } + .distinctUntilChanged() + .debounce(timeoutMillis = DRAFT_AUTOSAVE_DELAY_MILLIS) + .filterNotNull() + } } private fun updateDraftEditorState(draftEditorState: DraftEditorState) { @@ -211,7 +402,70 @@ internal class ConversationDraftDelegateImpl @Inject constructor( } } + private fun markConversationDraftAsIdle(conversationId: String) { + updateDraftEditorState { currentDraftEditorState -> + if (currentDraftEditorState.conversationId != conversationId) { + return@updateDraftEditorState currentDraftEditorState + } + + return@updateDraftEditorState currentDraftEditorState.markIdle() + } + } + + private fun clearConversationDraftAfterSend(sendRequest: DraftSendRequest) { + updateDraftEditorState { latestDraftEditorState -> + if (latestDraftEditorState.conversationId != sendRequest.conversationId) { + return@updateDraftEditorState latestDraftEditorState + } + + return@updateDraftEditorState latestDraftEditorState.clearDraftAfterSend( + sentDraft = sendRequest.draft, + ) + } + } + + private fun markSendingAndCreateSendRequestOrNull(): DraftSendRequest? { + var sendRequest: DraftSendRequest? = null + + updateDraftEditorState { currentDraftEditorState -> + if (!currentDraftEditorState.canSendDraft()) { + return@updateDraftEditorState currentDraftEditorState + } + + val conversationId = currentDraftEditorState + .conversationId + ?: return@updateDraftEditorState currentDraftEditorState + + sendRequest = DraftSendRequest( + conversationId = conversationId, + draft = currentDraftEditorState.effectiveDraft, + ) + + currentDraftEditorState.markSending() + } + + return sendRequest + } + + private fun runDraftOperationBoundary( + operationName: String, + conversationId: String?, + createFlow: () -> Flow, + ): Flow { + return flow { + emitAll(createFlow()) + }.catch { exception -> + LogUtil.e( + TAG, + "Failed to $operationName for conversation $conversationId", + exception, + ) + } + } + private companion object { + private const val TAG = "ConversationDraftDelegate" + private const val DRAFT_AUTOSAVE_DELAY_MILLIS = 300L } } @@ -221,11 +475,11 @@ private data class DraftEditorState( val persistedDraft: ConversationDraft = ConversationDraft(), val localEdits: ConversationDraftEdits = ConversationDraftEdits(), val isLoaded: Boolean = false, + val isSending: Boolean = false, + val pendingSentDraft: ConversationDraft? = null, ) { val effectiveDraft: ConversationDraft - get() { - return localEdits.applyTo(baseDraft = persistedDraft) - } + get() = localEdits.applyTo(baseDraft = persistedDraft) val visibleDraft: ConversationDraft get() { @@ -233,10 +487,20 @@ private data class DraftEditorState( return ConversationDraft() } - return effectiveDraft + return effectiveDraft.copy( + isCheckingDraft = !isLoaded, + isSending = isSending, + ) } fun withPersistedDraft(persistedDraft: ConversationDraft): DraftEditorState { + pendingSentDraft?.let { draft -> + return withPersistedDraftWhileAwaitingSentDraftClear( + persistedDraft = persistedDraft, + sentDraftAwaitingClear = draft, + ) + } + return copy( persistedDraft = persistedDraft, localEdits = localEdits.normalizedAgainst( @@ -261,7 +525,7 @@ private data class DraftEditorState( fun toSaveRequestOrNull(): DraftSaveRequest? { val currentConversationId = conversationId ?: return null - if (!isLoaded || !localEdits.hasChanges) { + if (!isLoaded || isSending || !localEdits.hasChanges) { return null } @@ -271,6 +535,19 @@ private data class DraftEditorState( ) } + fun canLaunchAttachmentChooser(): Boolean { + return conversationId != null && + isLoaded && + !isSending + } + + fun canSendDraft(): Boolean { + return conversationId != null && + isLoaded && + !isSending && + effectiveDraft.hasContent + } + fun markPersistedIfUnchanged(saveRequest: DraftSaveRequest): DraftEditorState { if (conversationId != saveRequest.conversationId) { return this @@ -284,10 +561,109 @@ private data class DraftEditorState( persistedDraft = saveRequest.draft, localEdits = ConversationDraftEdits(), isLoaded = true, + pendingSentDraft = null, + ) + } + + fun matchesSaveRequest(saveRequest: DraftSaveRequest): Boolean { + return toSaveRequestOrNull() == saveRequest + } + + fun markSending(): DraftEditorState { + if (conversationId == null) { + return this + } + + return copy(isSending = true) + } + + fun markIdle(): DraftEditorState { + return copy(isSending = false) + } + + fun clearDraftAfterSend(sentDraft: ConversationDraft): DraftEditorState { + val latestEffectiveDraft = effectiveDraft + + val clearedDraft = createClearedDraftForSentDraft( + sentDraft = sentDraft, + ) + + val visibleDraftAfterSend = when { + latestEffectiveDraft == sentDraft -> clearedDraft + + // Preserve edits made while the send is enqueued + else -> latestEffectiveDraft.copy( + selfParticipantId = sentDraft.selfParticipantId, + ) + } + + return copy( + persistedDraft = clearedDraft, + localEdits = createConversationDraftEdits( + baseDraft = clearedDraft, + targetDraft = visibleDraftAfterSend, + ), + isLoaded = true, + isSending = false, + pendingSentDraft = sentDraft, + ) + } + + private fun withPersistedDraftWhileAwaitingSentDraftClear( + persistedDraft: ConversationDraft, + sentDraftAwaitingClear: ConversationDraft, + ): DraftEditorState { + if (persistedDraft == sentDraftAwaitingClear) { + return rebaseVisibleDraftOnPersistedDraft( + persistedDraft = persistedDraft, + shouldKeepPendingSentDraft = true, + ) + } + + val clearedDraft = createClearedDraftForSentDraft( + sentDraft = sentDraftAwaitingClear, + ) + if (effectiveDraft == clearedDraft) { + return copy( + persistedDraft = persistedDraft, + localEdits = ConversationDraftEdits(), + isLoaded = true, + pendingSentDraft = null, + ) + } + + return rebaseVisibleDraftOnPersistedDraft( + persistedDraft = persistedDraft, + shouldKeepPendingSentDraft = false, + ) + } + + private fun rebaseVisibleDraftOnPersistedDraft( + persistedDraft: ConversationDraft, + shouldKeepPendingSentDraft: Boolean, + ): DraftEditorState { + val visibleDraft = effectiveDraft + + return copy( + persistedDraft = persistedDraft, + localEdits = createConversationDraftEdits( + baseDraft = persistedDraft, + targetDraft = visibleDraft, + ), + isLoaded = true, + pendingSentDraft = pendingSentDraft.takeIf { shouldKeepPendingSentDraft }, ) } } +private fun createClearedDraftForSentDraft( + sentDraft: ConversationDraft, +): ConversationDraft { + return ConversationDraft( + selfParticipantId = sentDraft.selfParticipantId, + ) +} + private data class ConversationDraftEdits( val messageText: String? = null, val subjectText: String? = null, @@ -329,7 +705,31 @@ private data class ConversationDraftEdits( } } -private data class DraftSaveRequest( +private fun createConversationDraftEdits( + baseDraft: ConversationDraft, + targetDraft: ConversationDraft, +): ConversationDraftEdits { + return ConversationDraftEdits( + messageText = targetDraft.messageText.takeUnless { value -> + value == baseDraft.messageText + }, + subjectText = targetDraft.subjectText.takeUnless { value -> + value == baseDraft.subjectText + }, + selfParticipantId = targetDraft.selfParticipantId.takeUnless { value -> + value == baseDraft.selfParticipantId + }, + attachments = targetDraft.attachments.takeUnless { value -> + value == baseDraft.attachments + }, + ) +} + +private data class DraftSaveRequest(val conversationId: String, val draft: ConversationDraft) + +private data class DraftSendRequest(val conversationId: String, val draft: ConversationDraft) + +private data class PersistedDraftUpdate( val conversationId: String, - val draft: ConversationDraft, + val persistedDraft: ConversationDraft, ) diff --git a/src/com/android/messaging/ui/conversation/v2/composer/delegate/ConversationDraftEffect.kt b/src/com/android/messaging/ui/conversation/v2/composer/delegate/ConversationDraftEffect.kt new file mode 100644 index 00000000..08f98c69 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/v2/composer/delegate/ConversationDraftEffect.kt @@ -0,0 +1,7 @@ +package com.android.messaging.ui.conversation.v2.composer.delegate + +internal sealed interface ConversationDraftEffect { + data class LaunchAttachmentChooser( + val conversationId: String, + ) : ConversationDraftEffect +} diff --git a/src/com/android/messaging/ui/conversation/v2/composer/mapper/ConversationComposerUiStateMapper.kt b/src/com/android/messaging/ui/conversation/v2/composer/mapper/ConversationComposerUiStateMapper.kt index 05906cf6..e9990605 100644 --- a/src/com/android/messaging/ui/conversation/v2/composer/mapper/ConversationComposerUiStateMapper.kt +++ b/src/com/android/messaging/ui/conversation/v2/composer/mapper/ConversationComposerUiStateMapper.kt @@ -21,6 +21,12 @@ internal class ConversationComposerUiStateMapperImpl @Inject constructor() : ): ConversationComposerUiState { val hasWorkingDraft = draft.hasContent + val isAttachmentActionEnabled = composerAvailability.isAttachmentActionEnabled && + !draft.isCheckingDraft && + !draft.isSending + + val isMessageFieldEnabled = composerAvailability.isMessageFieldEnabled + val isSendEnabled = composerAvailability.isSendAvailable && hasWorkingDraft && !draft.isCheckingDraft && @@ -30,8 +36,8 @@ internal class ConversationComposerUiStateMapperImpl @Inject constructor() : messageText = draft.messageText, subjectText = draft.subjectText, selfParticipantId = draft.selfParticipantId, - isMessageFieldEnabled = composerAvailability.isMessageFieldEnabled, - isAttachmentActionEnabled = composerAvailability.isAttachmentActionEnabled, + isMessageFieldEnabled = isMessageFieldEnabled, + isAttachmentActionEnabled = isAttachmentActionEnabled, isSendEnabled = isSendEnabled, hasWorkingDraft = hasWorkingDraft, isMms = draft.isMms, diff --git a/src/com/android/messaging/ui/conversation/v2/screen/ConversationViewModel.kt b/src/com/android/messaging/ui/conversation/v2/screen/ConversationViewModel.kt index 1c100eed..9dad6267 100644 --- a/src/com/android/messaging/ui/conversation/v2/screen/ConversationViewModel.kt +++ b/src/com/android/messaging/ui/conversation/v2/screen/ConversationViewModel.kt @@ -3,15 +3,23 @@ package com.android.messaging.ui.conversation.v2.screen import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.android.messaging.di.core.DefaultDispatcher import com.android.messaging.ui.conversation.v2.composer.delegate.ConversationDraftDelegate +import com.android.messaging.ui.conversation.v2.composer.delegate.ConversationDraftEffect import com.android.messaging.ui.conversation.v2.composer.mapper.ConversationComposerUiStateMapper import com.android.messaging.ui.conversation.v2.messages.delegate.ConversationMessagesDelegate import com.android.messaging.ui.conversation.v2.metadata.delegate.ConversationMetadataDelegate +import com.android.messaging.ui.conversation.v2.screen.model.ConversationScreenEffect +import com.android.messaging.ui.conversation.v2.screen.model.ConversationUiState import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel @@ -20,6 +28,8 @@ internal class ConversationViewModel @Inject constructor( private val conversationMessagesDelegate: ConversationMessagesDelegate, private val conversationMetadataDelegate: ConversationMetadataDelegate, private val conversationComposerUiStateMapper: ConversationComposerUiStateMapper, + @param:DefaultDispatcher + private val defaultDispatcher: CoroutineDispatcher, private val savedStateHandle: SavedStateHandle, ) : ViewModel() { @@ -27,6 +37,11 @@ internal class ConversationViewModel @Inject constructor( key = CONVERSATION_ID_KEY, initialValue = null, ) + private val _effects = MutableSharedFlow( + extraBufferCapacity = 1, + ) + + val effects = _effects.asSharedFlow() val uiState: StateFlow = combine( conversationMetadataDelegate.state, @@ -51,6 +66,7 @@ internal class ConversationViewModel @Inject constructor( init { initializeDelegates() + bindDelegateEffects() } private fun initializeDelegates() { @@ -79,11 +95,11 @@ internal class ConversationViewModel @Inject constructor( } fun onAttachmentClick() { - // TODO + conversationDraftDelegate.onAttachmentClick() } fun onSendClick() { - // TODO + conversationDraftDelegate.onSendClick() } fun persistDraft() { @@ -96,6 +112,22 @@ internal class ConversationViewModel @Inject constructor( super.onCleared() } + private fun bindDelegateEffects() { + viewModelScope.launch(defaultDispatcher) { + conversationDraftDelegate.effects.collect { effect -> + when (effect) { + is ConversationDraftEffect.LaunchAttachmentChooser -> { + _effects.emit( + ConversationScreenEffect.LaunchAttachmentChooser( + conversationId = effect.conversationId, + ) + ) + } + } + } + } + } + private companion object { private const val CONVERSATION_ID_KEY = "conversation_id" private const val STATEFLOW_STOP_TIMEOUT_MILLIS = 5_000L diff --git a/src/com/android/messaging/ui/conversation/v2/screen/model/ConversationScreenEffect.kt b/src/com/android/messaging/ui/conversation/v2/screen/model/ConversationScreenEffect.kt new file mode 100644 index 00000000..e768469e --- /dev/null +++ b/src/com/android/messaging/ui/conversation/v2/screen/model/ConversationScreenEffect.kt @@ -0,0 +1,7 @@ +package com.android.messaging.ui.conversation.v2.screen.model + +internal sealed interface ConversationScreenEffect { + data class LaunchAttachmentChooser( + val conversationId: String, + ) : ConversationScreenEffect +} diff --git a/src/com/android/messaging/ui/conversation/v2/screen/ConversationUiState.kt b/src/com/android/messaging/ui/conversation/v2/screen/model/ConversationUiState.kt similarity index 90% rename from src/com/android/messaging/ui/conversation/v2/screen/ConversationUiState.kt rename to src/com/android/messaging/ui/conversation/v2/screen/model/ConversationUiState.kt index cf6826bd..7f0ad8e1 100644 --- a/src/com/android/messaging/ui/conversation/v2/screen/ConversationUiState.kt +++ b/src/com/android/messaging/ui/conversation/v2/screen/model/ConversationUiState.kt @@ -1,4 +1,4 @@ -package com.android.messaging.ui.conversation.v2.screen +package com.android.messaging.ui.conversation.v2.screen.model import androidx.compose.runtime.Immutable import com.android.messaging.ui.conversation.v2.composer.model.ConversationComposerUiState diff --git a/src/com/android/messaging/util/core/extension/KotlinFlowExtensions.kt b/src/com/android/messaging/util/core/extension/KotlinFlowExtensions.kt index 7ee4b6ba..7e56023b 100644 --- a/src/com/android/messaging/util/core/extension/KotlinFlowExtensions.kt +++ b/src/com/android/messaging/util/core/extension/KotlinFlowExtensions.kt @@ -5,7 +5,7 @@ import kotlinx.coroutines.flow.FlowCollector import kotlinx.coroutines.flow.flow inline fun typedFlow( - crossinline block: suspend FlowCollector.() -> T + crossinline block: suspend FlowCollector.() -> T, ): Flow { return flow { val value = block() @@ -13,3 +13,12 @@ inline fun typedFlow( emit(value) } } + +inline fun unitFlow( + crossinline block: suspend FlowCollector.() -> Unit, +): Flow { + return flow { + block() + emit(Unit) + } +} From f87a51e84ce26332c3411af2ba20d1d2bd792d57 Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Sat, 28 Mar 2026 02:53:13 +0200 Subject: [PATCH 10/99] Compose bar and screen behavior improvements --- app/build.gradle.kts | 4 + gradle/libs.versions.toml | 8 + gradle/verification-metadata.xml | 98 ++++++++++ .../conversation/v2/ConversationTestTags.kt | 21 +++ .../v2/composer/ui/ConversationComposeBar.kt | 168 +++++++++--------- .../v2/messages/ui/ConversationMessages.kt | 12 +- .../v2/screen/ConversationAutoScrollPolicy.kt | 42 +++++ .../v2/screen/ConversationScreen.kt | 126 ++++++++++++- .../v2/screen/ConversationViewModel.kt | 33 ++-- 9 files changed, 407 insertions(+), 105 deletions(-) create mode 100644 src/com/android/messaging/ui/conversation/v2/ConversationTestTags.kt create mode 100644 src/com/android/messaging/ui/conversation/v2/screen/ConversationAutoScrollPolicy.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 9a2f88b5..dcb8143c 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -45,6 +45,7 @@ android { versionName = "13" minSdk = 35 targetSdk = 35 + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" ndk { abiFilters.clear() @@ -168,6 +169,9 @@ dependencies { androidTestImplementation(platform(libs.androidx.compose.bom)) androidTestImplementation(libs.androidx.compose.ui.test.junit4) + androidTestImplementation(libs.androidx.test.espresso.core) + androidTestImplementation(libs.androidx.test.ext.junit) + androidTestImplementation(libs.androidx.test.runner) androidTestImplementation(libs.hilt.android.testing) kspAndroidTest(libs.hilt.compiler) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 99fe9844..07429e0a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -27,6 +27,10 @@ mockk = "1.14.9" robolectric = "4.16.1" turbine = "1.2.1" +androidx-test-espresso = "3.7.0" +androidx-test-ext-junit = "1.3.0" +androidx-test-runner = "1.7.0" + [libraries] androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activity-compose" } androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "appcompat" } @@ -77,6 +81,10 @@ hilt-gradle-plugin = { module = "com.google.dagger:hilt-android-gradle-plugin", kotlin-gradle-plugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" } ksp-gradle-plugin = { module = "com.google.devtools.ksp:symbol-processing-gradle-plugin", version.ref = "ksp" } +androidx-test-espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "androidx-test-espresso" } +androidx-test-ext-junit = { module = "androidx.test.ext:junit", version.ref = "androidx-test-ext-junit" } +androidx-test-runner = { module = "androidx.test:runner", version.ref = "androidx-test-runner" } + [plugins] android-application = { id = "com.android.application", version.ref = "agp" } android-library = { id = "com.android.library", version.ref = "agp" } diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml index 96f92386..68d4b8eb 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -7072,5 +7072,103 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/com/android/messaging/ui/conversation/v2/ConversationTestTags.kt b/src/com/android/messaging/ui/conversation/v2/ConversationTestTags.kt new file mode 100644 index 00000000..8621debd --- /dev/null +++ b/src/com/android/messaging/ui/conversation/v2/ConversationTestTags.kt @@ -0,0 +1,21 @@ +package com.android.messaging.ui.conversation.v2 + +import androidx.compose.ui.semantics.SemanticsPropertyKey +import androidx.compose.ui.semantics.SemanticsPropertyReceiver + +internal const val CONVERSATION_COMPOSE_BAR_TEST_TAG = "conversation_compose_bar" +internal const val CONVERSATION_LOADING_INDICATOR_TEST_TAG = "conversation_loading_indicator" +internal const val CONVERSATION_MESSAGES_LIST_TEST_TAG = "conversation_messages_list" +internal const val CONVERSATION_SEND_BUTTON_SHAPE_CIRCLE = "circle" +internal const val CONVERSATION_SEND_BUTTON_TEST_TAG = "conversation_send_button" +internal const val CONVERSATION_TEXT_FIELD_TEST_TAG = "conversation_text_field" + +internal fun conversationMessageItemTestTag(messageId: String): String { + return "conversation_message_item_$messageId" +} + +internal val conversationShapeSemanticsKey = SemanticsPropertyKey( + name = "conversation_shape", +) + +internal var SemanticsPropertyReceiver.conversationShape by conversationShapeSemanticsKey diff --git a/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationComposeBar.kt b/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationComposeBar.kt index 32c9a379..4bb5e87a 100644 --- a/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationComposeBar.kt +++ b/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationComposeBar.kt @@ -5,16 +5,20 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.Send -import androidx.compose.material.icons.rounded.AddCircleOutline import androidx.compose.material.icons.rounded.Image +import androidx.compose.material3.FilledIconButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextField @@ -25,15 +29,28 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment 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.platform.testTag import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp import com.android.messaging.R +import com.android.messaging.ui.conversation.v2.CONVERSATION_COMPOSE_BAR_TEST_TAG +import com.android.messaging.ui.conversation.v2.CONVERSATION_SEND_BUTTON_SHAPE_CIRCLE +import com.android.messaging.ui.conversation.v2.CONVERSATION_SEND_BUTTON_TEST_TAG +import com.android.messaging.ui.conversation.v2.CONVERSATION_TEXT_FIELD_TEST_TAG +import com.android.messaging.ui.conversation.v2.conversationShape import com.android.messaging.ui.core.AppTheme private val CONVERSATION_COMPOSE_BAR_FIELD_CORNER_RADIUS = 28.dp +private val CONVERSATION_COMPOSE_BAR_FIELD_SHAPE = + RoundedCornerShape(size = CONVERSATION_COMPOSE_BAR_FIELD_CORNER_RADIUS) +private val CONVERSATION_COMPOSE_BAR_SINGLE_LINE_HEIGHT = 56.dp +private val CONVERSATION_COMPOSE_BAR_SEND_BUTTON_SIZE = CONVERSATION_COMPOSE_BAR_SINGLE_LINE_HEIGHT +private val CONVERSATION_COMPOSE_BAR_SEND_BUTTON_SPACING = 8.dp private val CONVERSATION_COMPOSE_BAR_TEXT_FIELD_PADDING_HORIZONTAL = 12.dp private val CONVERSATION_COMPOSE_BAR_TEXT_FIELD_PADDING_VERTICAL = 8.dp -private val CONVERSATION_COMPOSE_BAR_TRAILING_ACTION_SPACING = 2.dp private val CONVERSATION_COMPOSE_BAR_PREVIEW_PADDING_VERTICAL = 24.dp @Composable @@ -67,15 +84,11 @@ internal fun ConversationComposeBar( @Composable private fun rememberConversationComposeBarPresentation(): ConversationComposeBarPresentation { - val fieldShape = RoundedCornerShape(size = CONVERSATION_COMPOSE_BAR_FIELD_CORNER_RADIUS) val fieldColors = conversationComposeBarTextFieldColors() - return remember( - fieldShape, - fieldColors, - ) { + return remember(fieldColors) { ConversationComposeBarPresentation( - fieldShape = fieldShape, + fieldShape = CONVERSATION_COMPOSE_BAR_FIELD_SHAPE, fieldColors = fieldColors, ) } @@ -110,12 +123,12 @@ private fun ConversationComposeBarContainer( modifier: Modifier = Modifier, content: @Composable () -> Unit, ) { - Row( + Box( modifier = modifier .fillMaxWidth() .imePadding() - .navigationBarsPadding(), - horizontalArrangement = Arrangement.Center, + .navigationBarsPadding() + .testTag(CONVERSATION_COMPOSE_BAR_TEST_TAG), ) { content() } @@ -128,41 +141,50 @@ private fun ConversationComposeTextField( isAttachmentActionEnabled: Boolean, isSendActionEnabled: Boolean, presentation: ConversationComposeBarPresentation, - onAttachmentClick: (() -> Unit)?, + onAttachmentClick: () -> Unit, onMessageTextChange: (String) -> Unit, - onSendClick: (() -> Unit)?, + onSendClick: () -> Unit, ) { - TextField( + Row( modifier = Modifier .fillMaxWidth() .padding( horizontal = CONVERSATION_COMPOSE_BAR_TEXT_FIELD_PADDING_HORIZONTAL, vertical = CONVERSATION_COMPOSE_BAR_TEXT_FIELD_PADDING_VERTICAL, ), - value = messageText, - onValueChange = onMessageTextChange, - enabled = isMessageFieldEnabled, - shape = presentation.fieldShape, - colors = presentation.fieldColors, - placeholder = { - ConversationComposePlaceholder() - }, - leadingIcon = { - ConversationComposeLeadingAction( - enabled = isAttachmentActionEnabled && onAttachmentClick != null, - onClick = onAttachmentClick, - ) - }, - trailingIcon = { - ConversationComposeTrailingActions( - isAttachmentActionEnabled = isAttachmentActionEnabled, - isSendActionEnabled = isSendActionEnabled, - onAttachmentClick = onAttachmentClick, - onSendClick = onSendClick, - ) - }, - maxLines = 4, - ) + horizontalArrangement = Arrangement.spacedBy( + space = CONVERSATION_COMPOSE_BAR_SEND_BUTTON_SPACING + ), + verticalAlignment = Alignment.Bottom, + ) { + TextField( + modifier = Modifier + .weight(weight = 1f) + .testTag(CONVERSATION_TEXT_FIELD_TEST_TAG) + .heightIn(min = CONVERSATION_COMPOSE_BAR_SINGLE_LINE_HEIGHT), + value = messageText, + onValueChange = onMessageTextChange, + enabled = isMessageFieldEnabled, + shape = presentation.fieldShape, + colors = presentation.fieldColors, + placeholder = { + ConversationComposePlaceholder() + }, + trailingIcon = { + ConversationComposeImageAction( + enabled = isAttachmentActionEnabled, + onClick = onAttachmentClick, + ) + }, + minLines = 1, + maxLines = 4, + ) + + ConversationComposeSendAction( + enabled = isSendActionEnabled, + onClick = onSendClick, + ) + } } @Composable @@ -173,55 +195,17 @@ private fun ConversationComposePlaceholder() { ) } -@Composable -private fun ConversationComposeLeadingAction( - enabled: Boolean, - onClick: (() -> Unit)?, -) { - IconButton( - onClick = { - onClick?.invoke() - }, - enabled = enabled, - ) { - Icon( - imageVector = Icons.Rounded.AddCircleOutline, - contentDescription = null, - ) - } -} - -@Composable -private fun ConversationComposeTrailingActions( - isAttachmentActionEnabled: Boolean, - isSendActionEnabled: Boolean, - onAttachmentClick: (() -> Unit)?, - onSendClick: (() -> Unit)?, -) { - Row( - horizontalArrangement = Arrangement.spacedBy(space = CONVERSATION_COMPOSE_BAR_TRAILING_ACTION_SPACING), - verticalAlignment = Alignment.CenterVertically, - ) { - ConversationComposeImageAction( - enabled = isAttachmentActionEnabled && onAttachmentClick != null, - onClick = onAttachmentClick, - ) - - ConversationComposeSendAction( - enabled = isSendActionEnabled && onSendClick != null, - onClick = onSendClick, - ) - } -} - @Composable private fun ConversationComposeImageAction( enabled: Boolean, - onClick: (() -> Unit)?, + onClick: () -> Unit, ) { + val hapticFeedback = LocalHapticFeedback.current + IconButton( onClick = { - onClick?.invoke() + hapticFeedback.performHapticFeedback(HapticFeedbackType.ContextClick) + onClick() }, enabled = enabled, ) { @@ -235,13 +219,29 @@ private fun ConversationComposeImageAction( @Composable private fun ConversationComposeSendAction( enabled: Boolean, - onClick: (() -> Unit)?, + onClick: () -> Unit, ) { - IconButton( + val hapticFeedback = LocalHapticFeedback.current + + FilledIconButton( + modifier = Modifier + .testTag(CONVERSATION_SEND_BUTTON_TEST_TAG) + .semantics { + conversationShape = CONVERSATION_SEND_BUTTON_SHAPE_CIRCLE + } + .size(size = CONVERSATION_COMPOSE_BAR_SEND_BUTTON_SIZE), onClick = { - onClick?.invoke() + hapticFeedback.performHapticFeedback(HapticFeedbackType.ContextClick) + onClick() }, enabled = enabled, + shape = CircleShape, + colors = IconButtonDefaults.filledIconButtonColors( + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary, + disabledContainerColor = MaterialTheme.colorScheme.surfaceContainerHighest, + disabledContentColor = MaterialTheme.colorScheme.onSurfaceVariant, + ), ) { Icon( imageVector = Icons.AutoMirrored.Rounded.Send, diff --git a/src/com/android/messaging/ui/conversation/v2/messages/ui/ConversationMessages.kt b/src/com/android/messaging/ui/conversation/v2/messages/ui/ConversationMessages.kt index d77655c2..a7eace07 100644 --- a/src/com/android/messaging/ui/conversation/v2/messages/ui/ConversationMessages.kt +++ b/src/com/android/messaging/ui/conversation/v2/messages/ui/ConversationMessages.kt @@ -21,15 +21,18 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import com.android.messaging.ui.conversation.v2.CONVERSATION_MESSAGES_LIST_TEST_TAG +import com.android.messaging.ui.conversation.v2.conversationMessageItemTestTag import com.android.messaging.ui.conversation.v2.messages.model.ConversationMessageUiModel import java.time.LocalDate import java.util.TimeZone private const val COMMON_DATE_SEPARATOR_FORMAT_FLAGS = DateUtils.FORMAT_SHOW_WEEKDAY or - DateUtils.FORMAT_SHOW_DATE or - DateUtils.FORMAT_ABBREV_MONTH + DateUtils.FORMAT_SHOW_DATE or + DateUtils.FORMAT_ABBREV_MONTH private val CONVERSATION_MESSAGES_CONTENT_PADDING = PaddingValues( start = 16.dp, @@ -70,6 +73,7 @@ internal fun ConversationMessages( reverseLayout = true, modifier = modifier .fillMaxSize() + .testTag(CONVERSATION_MESSAGES_LIST_TEST_TAG) .background(color = MaterialTheme.colorScheme.background), contentPadding = CONVERSATION_MESSAGES_CONTENT_PADDING, ) { @@ -144,7 +148,9 @@ private fun ConversationMessagesItem( dateSeparatorText = presentation.dateSeparatorText, ) { ConversationMessage( - modifier = Modifier.padding(top = presentation.topPadding), + modifier = Modifier + .testTag(conversationMessageItemTestTag(messageId = message.messageId)) + .padding(top = presentation.topPadding), message = message, ) } diff --git a/src/com/android/messaging/ui/conversation/v2/screen/ConversationAutoScrollPolicy.kt b/src/com/android/messaging/ui/conversation/v2/screen/ConversationAutoScrollPolicy.kt new file mode 100644 index 00000000..f53e17dd --- /dev/null +++ b/src/com/android/messaging/ui/conversation/v2/screen/ConversationAutoScrollPolicy.kt @@ -0,0 +1,42 @@ +package com.android.messaging.ui.conversation.v2.screen + +internal data class ConversationAutoScrollInput( + val previousLatestMessageId: String?, + val latestMessageId: String?, + val hasLatestMessage: Boolean, + val isLatestMessageIncoming: Boolean, + val wasScrolledToLatestMessage: Boolean, +) + +internal data class ConversationAutoScrollDecision( + val shouldScrollToLatestMessage: Boolean, + val updatedLatestMessageId: String?, +) + +internal fun evaluateConversationAutoScroll( + input: ConversationAutoScrollInput, +): ConversationAutoScrollDecision { + return when { + input.latestMessageId == input.previousLatestMessageId -> ConversationAutoScrollDecision( + shouldScrollToLatestMessage = false, + updatedLatestMessageId = input.latestMessageId, + ) + + !input.hasLatestMessage -> ConversationAutoScrollDecision( + shouldScrollToLatestMessage = false, + updatedLatestMessageId = input.latestMessageId, + ) + + input.isLatestMessageIncoming && !input.wasScrolledToLatestMessage -> { + ConversationAutoScrollDecision( + shouldScrollToLatestMessage = false, + updatedLatestMessageId = input.latestMessageId, + ) + } + + else -> ConversationAutoScrollDecision( + shouldScrollToLatestMessage = true, + updatedLatestMessageId = input.latestMessageId, + ) + } +} diff --git a/src/com/android/messaging/ui/conversation/v2/screen/ConversationScreen.kt b/src/com/android/messaging/ui/conversation/v2/screen/ConversationScreen.kt index 212a1598..6cba9730 100644 --- a/src/com/android/messaging/ui/conversation/v2/screen/ConversationScreen.kt +++ b/src/com/android/messaging/ui/conversation/v2/screen/ConversationScreen.kt @@ -1,5 +1,8 @@ package com.android.messaging.ui.conversation.v2.screen +import android.content.Intent +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding @@ -9,34 +12,71 @@ import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag import androidx.lifecycle.Lifecycle import androidx.lifecycle.compose.LifecycleEventEffect import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel +import com.android.messaging.ui.UIIntents +import com.android.messaging.ui.attachmentchooser.AttachmentChooserActivity +import com.android.messaging.ui.conversation.v2.CONVERSATION_LOADING_INDICATOR_TEST_TAG import com.android.messaging.ui.conversation.v2.composer.ui.ConversationComposeBar +import com.android.messaging.ui.conversation.v2.messages.model.ConversationMessageUiModel import com.android.messaging.ui.conversation.v2.messages.model.ConversationMessagesUiState import com.android.messaging.ui.conversation.v2.messages.ui.ConversationMessages import com.android.messaging.ui.conversation.v2.metadata.ui.ConversationTopAppBar +import com.android.messaging.ui.conversation.v2.screen.model.ConversationScreenEffect +import com.android.messaging.ui.conversation.v2.screen.model.ConversationUiState @Composable internal fun ConversationScreen( modifier: Modifier = Modifier, conversationId: String? = null, onNavigateBack: () -> Unit = {}, - viewModel: ConversationViewModel = viewModel(), + screenModel: ConversationScreenModel = viewModel(), ) { + val context = LocalContext.current + val attachmentChooserLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.StartActivityForResult(), + ) {} + LaunchedEffect(conversationId) { - viewModel.onConversationChanged(conversationId = conversationId) + screenModel.onConversationChanged(conversationId = conversationId) + } + + LaunchedEffect(screenModel, context, attachmentChooserLauncher) { + screenModel.effects.collect { effect -> + when (effect) { + is ConversationScreenEffect.LaunchAttachmentChooser -> { + val chooserIntent = Intent( + context, + AttachmentChooserActivity::class.java, + ).apply { + putExtra( + UIIntents.UI_INTENT_EXTRA_CONVERSATION_ID, + effect.conversationId, + ) + } + + attachmentChooserLauncher.launch(chooserIntent) + } + } + } } LifecycleEventEffect(event = Lifecycle.Event.ON_STOP) { - viewModel.persistDraft() + screenModel.persistDraft() } - val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val uiState by screenModel.uiState.collectAsStateWithLifecycle() Scaffold( modifier = modifier.fillMaxSize(), @@ -52,11 +92,11 @@ internal fun ConversationScreen( isMessageFieldEnabled = uiState.composer.isMessageFieldEnabled, isAttachmentActionEnabled = uiState.composer.isAttachmentActionEnabled, isSendActionEnabled = uiState.composer.isSendEnabled, - onAttachmentClick = viewModel::onAttachmentClick, + onAttachmentClick = screenModel::onAttachmentClick, onMessageTextChange = { messageText -> - viewModel.onMessageTextChanged(text = messageText) + screenModel.onMessageTextChanged(text = messageText) }, - onSendClick = viewModel::onSendClick, + onSendClick = screenModel::onSendClick, ) }, ) { contentPadding -> @@ -80,7 +120,9 @@ private fun ConversationScreenContent( modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center, ) { - CircularProgressIndicator() + CircularProgressIndicator( + modifier = Modifier.testTag(CONVERSATION_LOADING_INDICATOR_TEST_TAG), + ) } } @@ -89,6 +131,12 @@ private fun ConversationScreenContent( conversationId = conversationId, ) + AutoScrollToLatestMessage( + conversationId = conversationId, + messages = messagesState.messages, + listState = messagesListState, + ) + ConversationMessages( modifier = modifier, messages = messagesState.messages, @@ -98,6 +146,68 @@ private fun ConversationScreenContent( } } +@Composable +private fun AutoScrollToLatestMessage( + conversationId: String?, + messages: List, + listState: LazyListState, +) { + val latestMessage = messages.lastOrNull() + val latestMessageId = latestMessage?.messageId + var previousLatestMessageId by remember(conversationId) { + mutableStateOf(value = latestMessageId) + } + var wasScrolledToLatestMessage by remember( + conversationId, + listState, + ) { + mutableStateOf( + value = isScrolledToLatestMessage(listState = listState), + ) + } + + LaunchedEffect( + conversationId, + listState, + ) { + snapshotFlow { + isScrolledToLatestMessage(listState = listState) + } + .collect { isScrolledToLatestMessage -> + wasScrolledToLatestMessage = isScrolledToLatestMessage + } + } + + LaunchedEffect( + conversationId, + latestMessageId, + ) { + val autoScrollDecision = evaluateConversationAutoScroll( + input = ConversationAutoScrollInput( + previousLatestMessageId = previousLatestMessageId, + latestMessageId = latestMessageId, + hasLatestMessage = latestMessage != null, + isLatestMessageIncoming = latestMessage?.isIncoming ?: false, + wasScrolledToLatestMessage = wasScrolledToLatestMessage, + ), + ) + + previousLatestMessageId = autoScrollDecision.updatedLatestMessageId + if (!autoScrollDecision.shouldScrollToLatestMessage) { + return@LaunchedEffect + } + + listState.animateScrollToItem(index = 0) + } +} + +private fun isScrolledToLatestMessage( + listState: LazyListState, +): Boolean { + return listState.firstVisibleItemIndex == 0 && + listState.firstVisibleItemScrollOffset == 0 +} + @Composable private fun rememberMessagesListState( conversationId: String?, diff --git a/src/com/android/messaging/ui/conversation/v2/screen/ConversationViewModel.kt b/src/com/android/messaging/ui/conversation/v2/screen/ConversationViewModel.kt index 9dad6267..6df59e09 100644 --- a/src/com/android/messaging/ui/conversation/v2/screen/ConversationViewModel.kt +++ b/src/com/android/messaging/ui/conversation/v2/screen/ConversationViewModel.kt @@ -12,7 +12,9 @@ import com.android.messaging.ui.conversation.v2.metadata.delegate.ConversationMe import com.android.messaging.ui.conversation.v2.screen.model.ConversationScreenEffect import com.android.messaging.ui.conversation.v2.screen.model.ConversationUiState import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow @@ -20,7 +22,17 @@ import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch -import javax.inject.Inject + +internal interface ConversationScreenModel { + val effects: Flow + val uiState: StateFlow + + fun onConversationChanged(conversationId: String?) + fun onMessageTextChanged(text: String) + fun onAttachmentClick() + fun onSendClick() + fun persistDraft() +} @HiltViewModel internal class ConversationViewModel @Inject constructor( @@ -31,7 +43,8 @@ internal class ConversationViewModel @Inject constructor( @param:DefaultDispatcher private val defaultDispatcher: CoroutineDispatcher, private val savedStateHandle: SavedStateHandle, -) : ViewModel() { +) : ViewModel(), + ConversationScreenModel { private val conversationIdFlow: StateFlow = savedStateHandle.getStateFlow( key = CONVERSATION_ID_KEY, @@ -41,9 +54,9 @@ internal class ConversationViewModel @Inject constructor( extraBufferCapacity = 1, ) - val effects = _effects.asSharedFlow() + override val effects = _effects.asSharedFlow() - val uiState: StateFlow = combine( + override val uiState: StateFlow = combine( conversationMetadataDelegate.state, conversationMessagesDelegate.state, conversationDraftDelegate.state, @@ -84,25 +97,25 @@ internal class ConversationViewModel @Inject constructor( ) } - fun onConversationChanged(conversationId: String?) { + override fun onConversationChanged(conversationId: String?) { if (conversationId != conversationIdFlow.value) { savedStateHandle[CONVERSATION_ID_KEY] = conversationId } } - fun onMessageTextChanged(text: String) { + override fun onMessageTextChanged(text: String) { conversationDraftDelegate.onMessageTextChanged(messageText = text) } - fun onAttachmentClick() { + override fun onAttachmentClick() { conversationDraftDelegate.onAttachmentClick() } - fun onSendClick() { + override fun onSendClick() { conversationDraftDelegate.onSendClick() } - fun persistDraft() { + override fun persistDraft() { conversationDraftDelegate.persistDraft() } @@ -120,7 +133,7 @@ internal class ConversationViewModel @Inject constructor( _effects.emit( ConversationScreenEffect.LaunchAttachmentChooser( conversationId = effect.conversationId, - ) + ), ) } } From 48a3ad394505bd9dca8c1b92482f3bcef9da573a Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Sat, 28 Mar 2026 06:27:24 +0200 Subject: [PATCH 11/99] Ignore .log files in Git --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index 43dc9de3..d92cac30 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,5 @@ keystore.properties *.keystore local.properties /lib/build + +*.log From 9cb3771197674152ef835965873f33b1b6151ee0 Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Sat, 28 Mar 2026 15:47:55 +0200 Subject: [PATCH 12/99] Add dependencies for media picker --- app/build.gradle.kts | 7 + gradle/libs.versions.toml | 9 + gradle/verification-metadata.xml | 537 +++++++++++++++++++++++++++++++ 3 files changed, 553 insertions(+) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index dcb8143c..4ae7de96 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -121,6 +121,13 @@ android { dependencies { implementation(libs.androidx.appcompat) + implementation(libs.androidx.camera.camera2) + implementation(libs.androidx.camera.compose) + implementation(libs.androidx.camera.core) + implementation(libs.androidx.camera.lifecycle) + implementation(libs.androidx.camera.video) + implementation(libs.androidx.paging.compose) + implementation(libs.androidx.paging.runtime) implementation(libs.androidx.palette) implementation(libs.androidx.preference) implementation(libs.androidx.recyclerview) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 07429e0a..a7091cb6 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -9,6 +9,7 @@ ktlint-gradle = "14.2.0" activity-compose = "1.13.0" appcompat = "1.7.1" +camerax = "1.6.0" coil = "3.4.0" compose-bom = "2026.03.01" coroutines = "1.10.2" @@ -17,6 +18,7 @@ guava = "33.5.0-android" jsr305 = "3.0.2" libphonenumber = "9.0.26" lifecycle = "2.10.0" +paging = "3.4.2" palette = "1.0.0" preference = "1.2.1" recyclerview = "1.4.0" @@ -34,6 +36,11 @@ androidx-test-runner = "1.7.0" [libraries] androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activity-compose" } androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "appcompat" } +androidx-camera-camera2 = { module = "androidx.camera:camera-camera2", version.ref = "camerax" } +androidx-camera-compose = { module = "androidx.camera:camera-compose", version.ref = "camerax" } +androidx-camera-core = { module = "androidx.camera:camera-core", version.ref = "camerax" } +androidx-camera-lifecycle = { module = "androidx.camera:camera-lifecycle", version.ref = "camerax" } +androidx-camera-video = { module = "androidx.camera:camera-video", version.ref = "camerax" } androidx-compose-bom = { module = "androidx.compose:compose-bom", version.ref = "compose-bom" } androidx-compose-foundation = { module = "androidx.compose.foundation:foundation" } @@ -51,6 +58,8 @@ androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-ru androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycle" } androidx-lifecycle-viewmodel-ktx = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "lifecycle" } +androidx-paging-compose = { module = "androidx.paging:paging-compose", version.ref = "paging" } +androidx-paging-runtime = { module = "androidx.paging:paging-runtime", version.ref = "paging" } androidx-palette = { module = "androidx.palette:palette", version.ref = "palette" } androidx-preference = { module = "androidx.preference:preference", version.ref = "preference" } androidx-recyclerview = { module = "androidx.recyclerview:recyclerview", version.ref = "recyclerview" } diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml index 68d4b8eb..00258e4c 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -7170,5 +7170,542 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + From deb94f3ed65fca44aab9fff8c4f1d7696f635921 Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Wed, 1 Apr 2026 23:45:50 +0300 Subject: [PATCH 13/99] Update Theme to better match Material Expressive shapes --- src/com/android/messaging/ui/core/Theme.kt | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/com/android/messaging/ui/core/Theme.kt b/src/com/android/messaging/ui/core/Theme.kt index 55a23ac4..6592a8d2 100644 --- a/src/com/android/messaging/ui/core/Theme.kt +++ b/src/com/android/messaging/ui/core/Theme.kt @@ -1,11 +1,22 @@ package com.android.messaging.ui.core import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Shapes import androidx.compose.material3.dynamicDarkColorScheme import androidx.compose.material3.dynamicLightColorScheme import androidx.compose.runtime.Composable import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp + +private val AppShapes = Shapes( + extraSmall = RoundedCornerShape(size = 12.dp), + small = RoundedCornerShape(size = 16.dp), + medium = RoundedCornerShape(size = 20.dp), + large = RoundedCornerShape(size = 28.dp), + extraLarge = RoundedCornerShape(size = 36.dp), +) @Composable fun AppTheme( @@ -19,6 +30,7 @@ fun AppTheme( MaterialTheme( colorScheme = colorScheme, + shapes = AppShapes, content = content, ) } From 8f9942006f37bd002530dc06eef99e8e8f496216 Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Thu, 2 Apr 2026 13:11:13 +0300 Subject: [PATCH 14/99] Add parcelize plugin --- app/build.gradle.kts | 1 + build.gradle.kts | 1 + gradle/libs.versions.toml | 1 + gradle/verification-metadata.xml | 27 +++++++++++++++++++++++++++ 4 files changed, 30 insertions(+) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 4ae7de96..f36bf0a7 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -7,6 +7,7 @@ plugins { alias(libs.plugins.detekt) alias(libs.plugins.hilt) alias(libs.plugins.kotlin.compose) + alias(libs.plugins.kotlin.parcelize) alias(libs.plugins.ksp) } diff --git a/build.gradle.kts b/build.gradle.kts index 55e7ddaa..07e20db4 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -6,6 +6,7 @@ plugins { alias(libs.plugins.android.application) apply false alias(libs.plugins.hilt) apply false alias(libs.plugins.kotlin.compose) apply false + alias(libs.plugins.kotlin.parcelize) apply false alias(libs.plugins.ksp) apply false alias(libs.plugins.ktlint) } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a7091cb6..b198e436 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -102,5 +102,6 @@ detekt = { id = "dev.detekt", version.ref = "detekt" } hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +kotlin-parcelize = { id = "org.jetbrains.kotlin.plugin.parcelize", version.ref = "kotlin" } ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } ktlint = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlint-gradle" } diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml index 00258e4c..f1550a9c 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -7707,5 +7707,32 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + From cfb759d7000d1957cc1ec32cb34c27a14fd1ed96 Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Thu, 2 Apr 2026 13:11:26 +0300 Subject: [PATCH 15/99] Update ktlint rules --- .editorconfig | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.editorconfig b/.editorconfig index 1cd48550..f2003881 100644 --- a/.editorconfig +++ b/.editorconfig @@ -14,9 +14,11 @@ ij_kotlin_allow_trailing_comma_on_call_site = true ij_kotlin_name_count_to_use_star_import = 2147483647 ij_kotlin_name_count_to_use_star_import_for_members = 2147483647 ij_kotlin_packages_to_use_import_on_demand = unset +ij_kotlin_line_break_after_multiline_when_entry = false ktlint_code_style = android_studio ktlint_function_naming_ignore_when_annotated_with = Composable ktlint_standard_function-expression-body = disabled ktlint_standard_function-signature = disabled ktlint_standard_trailing-comma-on-call-site = disabled +ktlint_standard_blank-line-between-when-conditions = disabled max_line_length = 100 From 61b203832b469174c06352acb26ceb09e454e71c Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Tue, 7 Apr 2026 17:23:58 +0300 Subject: [PATCH 16/99] Add immutable Kotlin collections dependency --- app/build.gradle.kts | 1 + gradle/libs.versions.toml | 2 ++ gradle/verification-metadata.xml | 16 ++++++++++++++++ 3 files changed, 19 insertions(+) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index f36bf0a7..88106ca8 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -156,6 +156,7 @@ dependencies { implementation(libs.guava) implementation(libs.jsr305) + implementation(libs.kotlinx.collections.immutable) implementation(libs.kotlinx.coroutines.android) implementation(libs.libphonenumber) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b198e436..a2ddb6a0 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -16,6 +16,7 @@ coroutines = "1.10.2" glide = "5.0.5" guava = "33.5.0-android" jsr305 = "3.0.2" +kotlinx-collections-immutable = "0.4.0" libphonenumber = "9.0.26" lifecycle = "2.10.0" paging = "3.4.2" @@ -74,6 +75,7 @@ hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" hilt-android-testing = { module = "com.google.dagger:hilt-android-testing", version.ref = "hilt" } hilt-compiler = { module = "com.google.dagger:hilt-compiler", version.ref = "hilt" } +kotlinx-collections-immutable = { module = "org.jetbrains.kotlinx:kotlinx-collections-immutable", version.ref = "kotlinx-collections-immutable" } kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" } kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" } diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml index f1550a9c..c588388a 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -7734,5 +7734,21 @@ + + + + + + + + + + + + + + + + From bd1c6a222d6142cb1e36925ab3e8b0f09403e04f Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Thu, 2 Apr 2026 13:45:52 +0300 Subject: [PATCH 17/99] Refactor conversation draft composer state --- .../ConversationDraftPendingAttachment.kt | 8 + .../delegate/ConversationDraftDelegate.kt | 413 ++++------------- .../delegate/ConversationDraftEditorState.kt | 415 ++++++++++++++++++ .../delegate/ConversationDraftEffect.kt | 7 - .../ConversationComposerUiStateMapper.kt | 41 +- .../ConversationComposerAttachmentUiState.kt | 28 ++ .../model/ConversationComposerUiState.kt | 3 + .../composer/model/ConversationDraftState.kt | 9 + .../ui/ConversationAttachmentPreview.kt | 197 +++++++++ .../v2/composer/ui/ConversationComposeBar.kt | 98 ++--- .../ui/ConversationComposerSection.kt | 46 ++ .../ui/ConversationSendActionButton.kt | 48 ++ 12 files changed, 905 insertions(+), 408 deletions(-) create mode 100644 src/com/android/messaging/data/conversation/model/draft/ConversationDraftPendingAttachment.kt create mode 100644 src/com/android/messaging/ui/conversation/v2/composer/delegate/ConversationDraftEditorState.kt delete mode 100644 src/com/android/messaging/ui/conversation/v2/composer/delegate/ConversationDraftEffect.kt create mode 100644 src/com/android/messaging/ui/conversation/v2/composer/model/ConversationComposerAttachmentUiState.kt create mode 100644 src/com/android/messaging/ui/conversation/v2/composer/model/ConversationDraftState.kt create mode 100644 src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationAttachmentPreview.kt create mode 100644 src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationComposerSection.kt create mode 100644 src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationSendActionButton.kt diff --git a/src/com/android/messaging/data/conversation/model/draft/ConversationDraftPendingAttachment.kt b/src/com/android/messaging/data/conversation/model/draft/ConversationDraftPendingAttachment.kt new file mode 100644 index 00000000..015d9282 --- /dev/null +++ b/src/com/android/messaging/data/conversation/model/draft/ConversationDraftPendingAttachment.kt @@ -0,0 +1,8 @@ +package com.android.messaging.data.conversation.model.draft + +internal data class ConversationDraftPendingAttachment( + val pendingAttachmentId: String, + val contentUri: String, + val contentType: String, + val displayName: String = "", +) diff --git a/src/com/android/messaging/ui/conversation/v2/composer/delegate/ConversationDraftDelegate.kt b/src/com/android/messaging/ui/conversation/v2/composer/delegate/ConversationDraftDelegate.kt index ef01fe8c..af0f650f 100644 --- a/src/com/android/messaging/ui/conversation/v2/composer/delegate/ConversationDraftDelegate.kt +++ b/src/com/android/messaging/ui/conversation/v2/composer/delegate/ConversationDraftDelegate.kt @@ -2,11 +2,13 @@ package com.android.messaging.ui.conversation.v2.composer.delegate import com.android.messaging.data.conversation.model.draft.ConversationDraft import com.android.messaging.data.conversation.model.draft.ConversationDraftAttachment +import com.android.messaging.data.conversation.model.draft.ConversationDraftPendingAttachment import com.android.messaging.data.conversation.repository.ConversationDraftsRepository import com.android.messaging.di.core.ApplicationCoroutineScope import com.android.messaging.di.core.DefaultDispatcher import com.android.messaging.domain.conversation.usecase.SendConversationDraft import com.android.messaging.ui.conversation.v2.common.ConversationScreenDelegate +import com.android.messaging.ui.conversation.v2.composer.model.ConversationDraftState import com.android.messaging.util.LogUtil import com.android.messaging.util.core.extension.unitFlow import javax.inject.Inject @@ -16,10 +18,8 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.collect @@ -38,12 +38,26 @@ import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext -internal interface ConversationDraftDelegate : ConversationScreenDelegate { - val effects: Flow - +internal interface ConversationDraftDelegate : ConversationScreenDelegate { fun onMessageTextChanged(messageText: String) - fun onAttachmentClick() + fun addAttachments(attachments: Collection) + + fun addPendingAttachment(pendingAttachment: ConversationDraftPendingAttachment) + + fun removeAttachment(contentUri: String) + + fun removePendingAttachment(pendingAttachmentId: String) + + fun resolvePendingAttachment( + pendingAttachmentId: String, + attachment: ConversationDraftAttachment, + ) + + fun updateAttachmentCaption( + contentUri: String, + captionText: String, + ) fun onSendClick() @@ -62,11 +76,7 @@ internal class ConversationDraftDelegateImpl @Inject constructor( private val defaultDispatcher: CoroutineDispatcher, ) : ConversationDraftDelegate { - private val _effects = MutableSharedFlow( - extraBufferCapacity = 1, - ) - private val _state = MutableStateFlow(ConversationDraft()) - override val effects = _effects.asSharedFlow() + private val _state = MutableStateFlow(ConversationDraftState()) override val state = _state.asStateFlow() private val draftEditorState = MutableStateFlow(DraftEditorState()) @@ -93,17 +103,59 @@ internal class ConversationDraftDelegateImpl @Inject constructor( override fun onMessageTextChanged(messageText: String) { updateDraftEditorState { currentDraftEditorState -> - return@updateDraftEditorState currentDraftEditorState.withMessageText( - messageText = messageText, - ) + currentDraftEditorState.withMessageText(messageText) } } - override fun onAttachmentClick() { - val scope = boundScope ?: return + override fun addAttachments(attachments: Collection) { + if (attachments.isEmpty()) { + return + } - launchDraftOperation(scope = scope) { - createAttachmentClickFlow() + updateDraftEditorState { currentDraftEditorState -> + currentDraftEditorState.withAttachmentsAdded(attachments) + } + } + + override fun addPendingAttachment(pendingAttachment: ConversationDraftPendingAttachment) { + updateDraftEditorState { currentDraftEditorState -> + currentDraftEditorState.withPendingAttachmentAdded(pendingAttachment) + } + } + + override fun removeAttachment(contentUri: String) { + updateDraftEditorState { currentDraftEditorState -> + currentDraftEditorState.withAttachmentRemoved(contentUri) + } + } + + override fun removePendingAttachment(pendingAttachmentId: String) { + updateDraftEditorState { currentDraftEditorState -> + currentDraftEditorState.withPendingAttachmentRemoved(pendingAttachmentId) + } + } + + override fun resolvePendingAttachment( + pendingAttachmentId: String, + attachment: ConversationDraftAttachment, + ) { + updateDraftEditorState { currentDraftEditorState -> + currentDraftEditorState.withPendingAttachmentResolved( + pendingAttachmentId = pendingAttachmentId, + attachment = attachment, + ) + } + } + + override fun updateAttachmentCaption( + contentUri: String, + captionText: String, + ) { + updateDraftEditorState { currentDraftEditorState -> + currentDraftEditorState.withAttachmentCaption( + contentUri = contentUri, + captionText = captionText, + ) } } @@ -112,9 +164,7 @@ internal class ConversationDraftDelegateImpl @Inject constructor( val sendRequest = markSendingAndCreateSendRequestOrNull() ?: return launchDraftOperation(scope = scope) { - createSendDraftFlow( - sendRequest = sendRequest, - ) + createSendDraftFlow(sendRequest) } } @@ -171,7 +221,7 @@ internal class ConversationDraftDelegateImpl @Inject constructor( } updateDraftEditorState { currentDraftEditorState -> - return@updateDraftEditorState currentDraftEditorState.markPersistedIfUnchanged( + currentDraftEditorState.markPersistedIfUnchanged( saveRequest = saveRequest, ) } @@ -214,15 +264,15 @@ internal class ConversationDraftDelegateImpl @Inject constructor( } private suspend fun resetDraftEditorState(conversationId: String?) { - val previousDraftEditorState = draftEditorState.value - updateDraftEditorState( - draftEditorState = DraftEditorState( - conversationId = conversationId, - ), - ) + var previousDraftEditorState: DraftEditorState? = null + + updateDraftEditorState { currentDraftEditorState -> + previousDraftEditorState = currentDraftEditorState + DraftEditorState(conversationId = conversationId) + } previousDraftEditorState - .toSaveRequestOrNull() + ?.toSaveRequestOrNull() ?.let { saveRequest -> createSaveDraftOperationFlow( operationName = "flush previous draft", @@ -243,36 +293,6 @@ internal class ConversationDraftDelegateImpl @Inject constructor( } } - private fun createAttachmentClickFlow(): Flow { - return runDraftOperationBoundary( - operationName = "launch attachment chooser", - conversationId = draftEditorState.value.conversationId, - ) { - unitFlow { - val currentDraftEditorState = draftEditorState.value - if (!currentDraftEditorState.canLaunchAttachmentChooser()) { - return@unitFlow - } - - val saveRequest = currentDraftEditorState.toSaveRequestOrNull() - if (saveRequest != null) { - saveDraft( - saveRequest = saveRequest, - shouldMarkCurrentDraftAsPersisted = true, - shouldSkipIfRequestIsStale = true, - ) - } - - val conversationId = draftEditorState.value.conversationId ?: return@unitFlow - _effects.emit( - value = ConversationDraftEffect.LaunchAttachmentChooser( - conversationId = conversationId, - ), - ) - } - } - } - private fun createSendDraftFlow(sendRequest: DraftSendRequest): Flow { var didClearDraftAfterSend = false @@ -388,15 +408,10 @@ internal class ConversationDraftDelegateImpl @Inject constructor( } } - private fun updateDraftEditorState(draftEditorState: DraftEditorState) { - this.draftEditorState.value = draftEditorState - _state.value = draftEditorState.visibleDraft - } - private fun updateDraftEditorState(transform: (DraftEditorState) -> DraftEditorState) { draftEditorState.update { currentDraftEditorState -> val updatedDraftEditorState = transform(currentDraftEditorState) - _state.value = updatedDraftEditorState.visibleDraft + _state.value = updatedDraftEditorState.visibleState updatedDraftEditorState } @@ -408,7 +423,7 @@ internal class ConversationDraftDelegateImpl @Inject constructor( return@updateDraftEditorState currentDraftEditorState } - return@updateDraftEditorState currentDraftEditorState.markIdle() + currentDraftEditorState.markIdle() } } @@ -418,7 +433,7 @@ internal class ConversationDraftDelegateImpl @Inject constructor( return@updateDraftEditorState latestDraftEditorState } - return@updateDraftEditorState latestDraftEditorState.clearDraftAfterSend( + latestDraftEditorState.clearDraftAfterSend( sentDraft = sendRequest.draft, ) } @@ -469,267 +484,3 @@ internal class ConversationDraftDelegateImpl @Inject constructor( private const val DRAFT_AUTOSAVE_DELAY_MILLIS = 300L } } - -private data class DraftEditorState( - val conversationId: String? = null, - val persistedDraft: ConversationDraft = ConversationDraft(), - val localEdits: ConversationDraftEdits = ConversationDraftEdits(), - val isLoaded: Boolean = false, - val isSending: Boolean = false, - val pendingSentDraft: ConversationDraft? = null, -) { - val effectiveDraft: ConversationDraft - get() = localEdits.applyTo(baseDraft = persistedDraft) - - val visibleDraft: ConversationDraft - get() { - if (conversationId == null) { - return ConversationDraft() - } - - return effectiveDraft.copy( - isCheckingDraft = !isLoaded, - isSending = isSending, - ) - } - - fun withPersistedDraft(persistedDraft: ConversationDraft): DraftEditorState { - pendingSentDraft?.let { draft -> - return withPersistedDraftWhileAwaitingSentDraftClear( - persistedDraft = persistedDraft, - sentDraftAwaitingClear = draft, - ) - } - - return copy( - persistedDraft = persistedDraft, - localEdits = localEdits.normalizedAgainst( - baseDraft = persistedDraft, - ), - isLoaded = true, - ) - } - - fun withMessageText(messageText: String): DraftEditorState { - if (conversationId == null) { - return this - } - - return copy( - localEdits = localEdits - .copy(messageText = messageText) - .normalizedAgainst(baseDraft = persistedDraft), - ) - } - - fun toSaveRequestOrNull(): DraftSaveRequest? { - val currentConversationId = conversationId ?: return null - - if (!isLoaded || isSending || !localEdits.hasChanges) { - return null - } - - return DraftSaveRequest( - conversationId = currentConversationId, - draft = effectiveDraft, - ) - } - - fun canLaunchAttachmentChooser(): Boolean { - return conversationId != null && - isLoaded && - !isSending - } - - fun canSendDraft(): Boolean { - return conversationId != null && - isLoaded && - !isSending && - effectiveDraft.hasContent - } - - fun markPersistedIfUnchanged(saveRequest: DraftSaveRequest): DraftEditorState { - if (conversationId != saveRequest.conversationId) { - return this - } - - if (effectiveDraft != saveRequest.draft) { - return this - } - - return copy( - persistedDraft = saveRequest.draft, - localEdits = ConversationDraftEdits(), - isLoaded = true, - pendingSentDraft = null, - ) - } - - fun matchesSaveRequest(saveRequest: DraftSaveRequest): Boolean { - return toSaveRequestOrNull() == saveRequest - } - - fun markSending(): DraftEditorState { - if (conversationId == null) { - return this - } - - return copy(isSending = true) - } - - fun markIdle(): DraftEditorState { - return copy(isSending = false) - } - - fun clearDraftAfterSend(sentDraft: ConversationDraft): DraftEditorState { - val latestEffectiveDraft = effectiveDraft - - val clearedDraft = createClearedDraftForSentDraft( - sentDraft = sentDraft, - ) - - val visibleDraftAfterSend = when { - latestEffectiveDraft == sentDraft -> clearedDraft - - // Preserve edits made while the send is enqueued - else -> latestEffectiveDraft.copy( - selfParticipantId = sentDraft.selfParticipantId, - ) - } - - return copy( - persistedDraft = clearedDraft, - localEdits = createConversationDraftEdits( - baseDraft = clearedDraft, - targetDraft = visibleDraftAfterSend, - ), - isLoaded = true, - isSending = false, - pendingSentDraft = sentDraft, - ) - } - - private fun withPersistedDraftWhileAwaitingSentDraftClear( - persistedDraft: ConversationDraft, - sentDraftAwaitingClear: ConversationDraft, - ): DraftEditorState { - if (persistedDraft == sentDraftAwaitingClear) { - return rebaseVisibleDraftOnPersistedDraft( - persistedDraft = persistedDraft, - shouldKeepPendingSentDraft = true, - ) - } - - val clearedDraft = createClearedDraftForSentDraft( - sentDraft = sentDraftAwaitingClear, - ) - if (effectiveDraft == clearedDraft) { - return copy( - persistedDraft = persistedDraft, - localEdits = ConversationDraftEdits(), - isLoaded = true, - pendingSentDraft = null, - ) - } - - return rebaseVisibleDraftOnPersistedDraft( - persistedDraft = persistedDraft, - shouldKeepPendingSentDraft = false, - ) - } - - private fun rebaseVisibleDraftOnPersistedDraft( - persistedDraft: ConversationDraft, - shouldKeepPendingSentDraft: Boolean, - ): DraftEditorState { - val visibleDraft = effectiveDraft - - return copy( - persistedDraft = persistedDraft, - localEdits = createConversationDraftEdits( - baseDraft = persistedDraft, - targetDraft = visibleDraft, - ), - isLoaded = true, - pendingSentDraft = pendingSentDraft.takeIf { shouldKeepPendingSentDraft }, - ) - } -} - -private fun createClearedDraftForSentDraft( - sentDraft: ConversationDraft, -): ConversationDraft { - return ConversationDraft( - selfParticipantId = sentDraft.selfParticipantId, - ) -} - -private data class ConversationDraftEdits( - val messageText: String? = null, - val subjectText: String? = null, - val selfParticipantId: String? = null, - val attachments: List? = null, -) { - val hasChanges: Boolean - get() { - return messageText != null || - subjectText != null || - selfParticipantId != null || - attachments != null - } - - fun applyTo(baseDraft: ConversationDraft): ConversationDraft { - return baseDraft.copy( - messageText = messageText ?: baseDraft.messageText, - subjectText = subjectText ?: baseDraft.subjectText, - selfParticipantId = selfParticipantId ?: baseDraft.selfParticipantId, - attachments = attachments ?: baseDraft.attachments, - ) - } - - fun normalizedAgainst(baseDraft: ConversationDraft): ConversationDraftEdits { - return ConversationDraftEdits( - messageText = messageText?.takeUnless { value -> - value == baseDraft.messageText - }, - subjectText = subjectText?.takeUnless { value -> - value == baseDraft.subjectText - }, - selfParticipantId = selfParticipantId?.takeUnless { value -> - value == baseDraft.selfParticipantId - }, - attachments = attachments?.takeUnless { value -> - value == baseDraft.attachments - }, - ) - } -} - -private fun createConversationDraftEdits( - baseDraft: ConversationDraft, - targetDraft: ConversationDraft, -): ConversationDraftEdits { - return ConversationDraftEdits( - messageText = targetDraft.messageText.takeUnless { value -> - value == baseDraft.messageText - }, - subjectText = targetDraft.subjectText.takeUnless { value -> - value == baseDraft.subjectText - }, - selfParticipantId = targetDraft.selfParticipantId.takeUnless { value -> - value == baseDraft.selfParticipantId - }, - attachments = targetDraft.attachments.takeUnless { value -> - value == baseDraft.attachments - }, - ) -} - -private data class DraftSaveRequest(val conversationId: String, val draft: ConversationDraft) - -private data class DraftSendRequest(val conversationId: String, val draft: ConversationDraft) - -private data class PersistedDraftUpdate( - val conversationId: String, - val persistedDraft: ConversationDraft, -) diff --git a/src/com/android/messaging/ui/conversation/v2/composer/delegate/ConversationDraftEditorState.kt b/src/com/android/messaging/ui/conversation/v2/composer/delegate/ConversationDraftEditorState.kt new file mode 100644 index 00000000..92ddea4c --- /dev/null +++ b/src/com/android/messaging/ui/conversation/v2/composer/delegate/ConversationDraftEditorState.kt @@ -0,0 +1,415 @@ +package com.android.messaging.ui.conversation.v2.composer.delegate + +import com.android.messaging.data.conversation.model.draft.ConversationDraft +import com.android.messaging.data.conversation.model.draft.ConversationDraftAttachment +import com.android.messaging.data.conversation.model.draft.ConversationDraftPendingAttachment +import com.android.messaging.ui.conversation.v2.composer.model.ConversationDraftState + +internal data class DraftEditorState( + val conversationId: String? = null, + val persistedDraft: ConversationDraft = ConversationDraft(), + private val localEdits: ConversationDraftEdits = ConversationDraftEdits(), + val isLoaded: Boolean = false, + val isSending: Boolean = false, + val pendingAttachments: List = emptyList(), + val pendingSentDraft: ConversationDraft? = null, +) { + val effectiveDraft: ConversationDraft + get() = localEdits.applyTo(baseDraft = persistedDraft) + + val visibleState: ConversationDraftState + get() { + return when { + conversationId == null -> ConversationDraftState() + + else -> { + ConversationDraftState( + draft = effectiveDraft.copy( + isCheckingDraft = !isLoaded, + isSending = isSending, + ), + pendingAttachments = pendingAttachments, + ) + } + } + } + + fun withPersistedDraft(persistedDraft: ConversationDraft): DraftEditorState { + return when { + pendingSentDraft != null -> { + withPersistedDraftWhileAwaitingSentDraftClear( + persistedDraft = persistedDraft, + sentDraftAwaitingClear = pendingSentDraft, + ) + } + + else -> { + copy( + persistedDraft = persistedDraft, + localEdits = localEdits.normalizedAgainst( + baseDraft = persistedDraft, + ), + isLoaded = true, + ) + } + } + } + + fun withMessageText(messageText: String): DraftEditorState { + return when { + conversationId == null -> this + effectiveDraft.messageText == messageText -> this + + else -> copyWithNormalizedLocalEdits( + updatedLocalEdits = localEdits.copy(messageText = messageText), + ) + } + } + + fun toSaveRequestOrNull(): DraftSaveRequest? { + return when { + conversationId == null -> null + !isLoaded || isSending || !localEdits.hasChanges -> null + + else -> { + DraftSaveRequest( + conversationId = conversationId, + draft = effectiveDraft, + ) + } + } + } + + fun withAttachmentsAdded( + attachments: Collection, + ): DraftEditorState { + if (conversationId == null || attachments.isEmpty()) { + return this + } + + val mergedAttachments = mergeDraftAttachments( + baseAttachments = effectiveDraft.attachments, + attachmentsToAdd = attachments, + ) + + return when { + mergedAttachments == effectiveDraft.attachments -> this + + else -> { + copyWithNormalizedLocalEdits( + updatedLocalEdits = localEdits.copy( + attachments = mergedAttachments, + ), + ) + } + } + } + + fun withAttachmentRemoved(contentUri: String): DraftEditorState { + if (conversationId == null) { + return this + } + + val attachmentIndex = effectiveDraft.attachments.indexOfFirst { attachment -> + attachment.contentUri == contentUri + } + + if (attachmentIndex == -1) { + return this + } + + val updatedAttachments = effectiveDraft.attachments.toMutableList().apply { + removeAt(attachmentIndex) + } + + return copyWithNormalizedLocalEdits( + updatedLocalEdits = localEdits.copy(attachments = updatedAttachments), + ) + } + + fun withAttachmentCaption( + contentUri: String, + captionText: String, + ): DraftEditorState { + if (conversationId == null) { + return this + } + + val currentAttachments = effectiveDraft.attachments + val attachmentIndex = currentAttachments.indexOfFirst { attachment -> + attachment.contentUri == contentUri + } + if (attachmentIndex == -1) { + return this + } + + val currentAttachment = currentAttachments[attachmentIndex] + if (currentAttachment.captionText == captionText) { + return this + } + + val updatedAttachments = currentAttachments.toMutableList().apply { + this[attachmentIndex] = currentAttachment.copy(captionText = captionText) + } + + return copyWithNormalizedLocalEdits( + updatedLocalEdits = localEdits.copy(attachments = updatedAttachments), + ) + } + + fun withPendingAttachmentAdded( + pendingAttachment: ConversationDraftPendingAttachment, + ): DraftEditorState { + if (conversationId == null) { + return this + } + + val updatedPendingAttachments = pendingAttachments + pendingAttachment + + return copy(pendingAttachments = updatedPendingAttachments) + } + + fun withPendingAttachmentRemoved(pendingAttachmentId: String): DraftEditorState { + val pendingAttachmentIndex = pendingAttachments.indexOfFirst { pendingAttachment -> + pendingAttachment.pendingAttachmentId == pendingAttachmentId + } + + if (pendingAttachmentIndex == -1) { + return this + } + + val updatedPendingAttachments = pendingAttachments.toMutableList().apply { + removeAt(pendingAttachmentIndex) + } + + return copy(pendingAttachments = updatedPendingAttachments) + } + + fun withPendingAttachmentResolved( + pendingAttachmentId: String, + attachment: ConversationDraftAttachment, + ): DraftEditorState { + val updatedState = withPendingAttachmentRemoved(pendingAttachmentId) + + return updatedState.withAttachmentsAdded(listOf(attachment)) + } + + fun canSendDraft(): Boolean { + return conversationId != null && + isLoaded && + !isSending && + pendingAttachments.isEmpty() && + effectiveDraft.hasContent + } + + fun markPersistedIfUnchanged(saveRequest: DraftSaveRequest): DraftEditorState { + return when { + conversationId != saveRequest.conversationId -> this + + effectiveDraft != saveRequest.draft -> this + + else -> copy( + persistedDraft = saveRequest.draft, + localEdits = ConversationDraftEdits(), + isLoaded = true, + pendingSentDraft = null, + ) + } + } + + fun matchesSaveRequest(saveRequest: DraftSaveRequest): Boolean { + return when { + conversationId != saveRequest.conversationId -> false + !isLoaded || isSending || !localEdits.hasChanges -> false + else -> effectiveDraft == saveRequest.draft + } + } + + fun markSending(): DraftEditorState { + return when { + conversationId == null -> this + isSending -> this + else -> copy(isSending = true) + } + } + + fun markIdle(): DraftEditorState { + if (!isSending) { + return this + } + + return copy(isSending = false) + } + + fun clearDraftAfterSend(sentDraft: ConversationDraft): DraftEditorState { + val latestEffectiveDraft = effectiveDraft + + val clearedDraft = createClearedDraftForSentDraft(sentDraft) + + val visibleDraftAfterSend = when { + latestEffectiveDraft == sentDraft -> clearedDraft + + else -> latestEffectiveDraft.copy( + selfParticipantId = sentDraft.selfParticipantId, + ) + } + + return copy( + persistedDraft = clearedDraft, + localEdits = createConversationDraftEdits( + baseDraft = clearedDraft, + targetDraft = visibleDraftAfterSend, + ), + isLoaded = true, + isSending = false, + pendingSentDraft = sentDraft, + ) + } + + private fun withPersistedDraftWhileAwaitingSentDraftClear( + persistedDraft: ConversationDraft, + sentDraftAwaitingClear: ConversationDraft, + ): DraftEditorState { + val currentEffectiveDraft = effectiveDraft + + if (persistedDraft == sentDraftAwaitingClear) { + return rebaseVisibleDraftOnPersistedDraft( + persistedDraft = persistedDraft, + shouldKeepPendingSentDraft = true, + ) + } + + val clearedDraft = createClearedDraftForSentDraft(sentDraftAwaitingClear) + if (currentEffectiveDraft == clearedDraft) { + return copy( + persistedDraft = persistedDraft, + localEdits = ConversationDraftEdits(), + isLoaded = true, + pendingSentDraft = null, + ) + } + + return rebaseVisibleDraftOnPersistedDraft( + persistedDraft = persistedDraft, + shouldKeepPendingSentDraft = false, + ) + } + + private fun rebaseVisibleDraftOnPersistedDraft( + persistedDraft: ConversationDraft, + shouldKeepPendingSentDraft: Boolean, + ): DraftEditorState { + return copy( + persistedDraft = persistedDraft, + localEdits = createConversationDraftEdits( + baseDraft = persistedDraft, + targetDraft = effectiveDraft, + ), + isLoaded = true, + pendingSentDraft = pendingSentDraft.takeIf { shouldKeepPendingSentDraft }, + ) + } + + private fun copyWithNormalizedLocalEdits( + updatedLocalEdits: ConversationDraftEdits, + ): DraftEditorState { + return copy( + localEdits = updatedLocalEdits.normalizedAgainst( + baseDraft = persistedDraft, + ), + ) + } +} + +internal data class DraftSaveRequest( + val conversationId: String, + val draft: ConversationDraft, +) + +internal data class DraftSendRequest( + val conversationId: String, + val draft: ConversationDraft, +) + +internal data class PersistedDraftUpdate( + val conversationId: String, + val persistedDraft: ConversationDraft, +) + +internal data class ConversationDraftEdits( + val messageText: String? = null, + val subjectText: String? = null, + val selfParticipantId: String? = null, + val attachments: List? = null, +) { + val hasChanges: Boolean + get() { + return messageText != null || + subjectText != null || + selfParticipantId != null || + attachments != null + } + + fun applyTo(baseDraft: ConversationDraft): ConversationDraft { + return baseDraft.copy( + messageText = messageText ?: baseDraft.messageText, + subjectText = subjectText ?: baseDraft.subjectText, + selfParticipantId = selfParticipantId ?: baseDraft.selfParticipantId, + attachments = attachments ?: baseDraft.attachments, + ) + } + + fun normalizedAgainst(baseDraft: ConversationDraft): ConversationDraftEdits { + return ConversationDraftEdits( + messageText = messageText?.takeIf { it != baseDraft.messageText }, + subjectText = subjectText?.takeIf { it != baseDraft.subjectText }, + selfParticipantId = selfParticipantId?.takeIf { it != baseDraft.selfParticipantId }, + attachments = attachments?.takeIf { it != baseDraft.attachments }, + ) + } +} + +private fun mergeDraftAttachments( + baseAttachments: List, + attachmentsToAdd: Collection, +): List { + if (attachmentsToAdd.isEmpty()) { + return baseAttachments + } + + val seenContentUris = baseAttachments + .asSequence() + .map { attachment -> attachment.contentUri } + .toHashSet() + + val attachmentsToAppend = attachmentsToAdd.filter { attachment -> + seenContentUris.add(attachment.contentUri) + } + + return when { + attachmentsToAppend.isEmpty() -> baseAttachments + else -> baseAttachments + attachmentsToAppend + } +} + +private fun createClearedDraftForSentDraft( + sentDraft: ConversationDraft, +): ConversationDraft { + return ConversationDraft( + selfParticipantId = sentDraft.selfParticipantId, + ) +} + +private fun createConversationDraftEdits( + baseDraft: ConversationDraft, + targetDraft: ConversationDraft, +): ConversationDraftEdits { + return ConversationDraftEdits( + messageText = targetDraft.messageText.takeIf { it != baseDraft.messageText }, + subjectText = targetDraft.subjectText.takeIf { it != baseDraft.subjectText }, + selfParticipantId = targetDraft.selfParticipantId.takeIf { + it != baseDraft.selfParticipantId + }, + attachments = targetDraft.attachments.takeIf { it != baseDraft.attachments }, + ) +} diff --git a/src/com/android/messaging/ui/conversation/v2/composer/delegate/ConversationDraftEffect.kt b/src/com/android/messaging/ui/conversation/v2/composer/delegate/ConversationDraftEffect.kt deleted file mode 100644 index 08f98c69..00000000 --- a/src/com/android/messaging/ui/conversation/v2/composer/delegate/ConversationDraftEffect.kt +++ /dev/null @@ -1,7 +0,0 @@ -package com.android.messaging.ui.conversation.v2.composer.delegate - -internal sealed interface ConversationDraftEffect { - data class LaunchAttachmentChooser( - val conversationId: String, - ) : ConversationDraftEffect -} diff --git a/src/com/android/messaging/ui/conversation/v2/composer/mapper/ConversationComposerUiStateMapper.kt b/src/com/android/messaging/ui/conversation/v2/composer/mapper/ConversationComposerUiStateMapper.kt index e9990605..c4beb3d4 100644 --- a/src/com/android/messaging/ui/conversation/v2/composer/mapper/ConversationComposerUiStateMapper.kt +++ b/src/com/android/messaging/ui/conversation/v2/composer/mapper/ConversationComposerUiStateMapper.kt @@ -1,13 +1,16 @@ package com.android.messaging.ui.conversation.v2.composer.mapper -import com.android.messaging.data.conversation.model.draft.ConversationDraft import com.android.messaging.data.conversation.model.metadata.ConversationComposerAvailability +import com.android.messaging.ui.conversation.v2.composer.model.ConversationComposerAttachmentUiState import com.android.messaging.ui.conversation.v2.composer.model.ConversationComposerUiState +import com.android.messaging.ui.conversation.v2.composer.model.ConversationDraftState +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList import javax.inject.Inject internal interface ConversationComposerUiStateMapper { fun map( - draft: ConversationDraft, + draftState: ConversationDraftState, composerAvailability: ConversationComposerAvailability, ): ConversationComposerUiState } @@ -16,9 +19,10 @@ internal class ConversationComposerUiStateMapperImpl @Inject constructor() : ConversationComposerUiStateMapper { override fun map( - draft: ConversationDraft, + draftState: ConversationDraftState, composerAvailability: ConversationComposerAvailability, ): ConversationComposerUiState { + val draft = draftState.draft val hasWorkingDraft = draft.hasContent val isAttachmentActionEnabled = composerAvailability.isAttachmentActionEnabled && @@ -30,9 +34,11 @@ internal class ConversationComposerUiStateMapperImpl @Inject constructor() : val isSendEnabled = composerAvailability.isSendAvailable && hasWorkingDraft && !draft.isCheckingDraft && - !draft.isSending + !draft.isSending && + draftState.pendingAttachments.isEmpty() return ConversationComposerUiState( + attachments = draftState.toAttachmentUiState(), messageText = draft.messageText, subjectText = draft.subjectText, selfParticipantId = draft.selfParticipantId, @@ -42,7 +48,7 @@ internal class ConversationComposerUiStateMapperImpl @Inject constructor() : hasWorkingDraft = hasWorkingDraft, isMms = draft.isMms, attachmentCount = draft.attachments.size, - pendingAttachmentCount = 0, + pendingAttachmentCount = draftState.pendingAttachments.size, messageCount = draft.messageCount, codePointsRemainingInCurrentMessage = draft.codePointsRemainingInCurrentMessage, isCheckingDraft = draft.isCheckingDraft, @@ -50,4 +56,29 @@ internal class ConversationComposerUiStateMapperImpl @Inject constructor() : disabledReason = composerAvailability.disabledReason, ) } + + private fun ConversationDraftState.toAttachmentUiState(): + ImmutableList { + val resolvedAttachments = draft.attachments.map { attachment -> + ConversationComposerAttachmentUiState.Resolved( + key = attachment.contentUri, + contentType = attachment.contentType, + contentUri = attachment.contentUri, + captionText = attachment.captionText, + width = attachment.width, + height = attachment.height, + ) + } + + val pendingAttachments = pendingAttachments.map { pendingAttachment -> + ConversationComposerAttachmentUiState.Pending( + key = pendingAttachment.pendingAttachmentId, + contentType = pendingAttachment.contentType, + contentUri = pendingAttachment.contentUri, + displayName = pendingAttachment.displayName, + ) + } + + return (resolvedAttachments + pendingAttachments).toImmutableList() + } } diff --git a/src/com/android/messaging/ui/conversation/v2/composer/model/ConversationComposerAttachmentUiState.kt b/src/com/android/messaging/ui/conversation/v2/composer/model/ConversationComposerAttachmentUiState.kt new file mode 100644 index 00000000..92ecd045 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/v2/composer/model/ConversationComposerAttachmentUiState.kt @@ -0,0 +1,28 @@ +package com.android.messaging.ui.conversation.v2.composer.model + +import androidx.compose.runtime.Immutable + +@Immutable +internal sealed interface ConversationComposerAttachmentUiState { + val key: String + val contentType: String + val contentUri: String + + @Immutable + data class Pending( + override val key: String, + override val contentType: String, + override val contentUri: String, + val displayName: String, + ) : ConversationComposerAttachmentUiState + + @Immutable + data class Resolved( + override val key: String, + override val contentType: String, + override val contentUri: String, + val captionText: String, + val width: Int?, + val height: Int?, + ) : ConversationComposerAttachmentUiState +} diff --git a/src/com/android/messaging/ui/conversation/v2/composer/model/ConversationComposerUiState.kt b/src/com/android/messaging/ui/conversation/v2/composer/model/ConversationComposerUiState.kt index 1c3a15a7..3237f770 100644 --- a/src/com/android/messaging/ui/conversation/v2/composer/model/ConversationComposerUiState.kt +++ b/src/com/android/messaging/ui/conversation/v2/composer/model/ConversationComposerUiState.kt @@ -2,9 +2,12 @@ package com.android.messaging.ui.conversation.v2.composer.model import androidx.compose.runtime.Immutable import com.android.messaging.data.conversation.model.metadata.ConversationComposerDisabledReason +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf @Immutable internal data class ConversationComposerUiState( + val attachments: ImmutableList = persistentListOf(), val messageText: String = "", val subjectText: String = "", val selfParticipantId: String = "", diff --git a/src/com/android/messaging/ui/conversation/v2/composer/model/ConversationDraftState.kt b/src/com/android/messaging/ui/conversation/v2/composer/model/ConversationDraftState.kt new file mode 100644 index 00000000..c27c9308 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/v2/composer/model/ConversationDraftState.kt @@ -0,0 +1,9 @@ +package com.android.messaging.ui.conversation.v2.composer.model + +import com.android.messaging.data.conversation.model.draft.ConversationDraft +import com.android.messaging.data.conversation.model.draft.ConversationDraftPendingAttachment + +internal data class ConversationDraftState( + val draft: ConversationDraft = ConversationDraft(), + val pendingAttachments: List = emptyList(), +) diff --git a/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationAttachmentPreview.kt b/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationAttachmentPreview.kt new file mode 100644 index 00000000..85d76002 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationAttachmentPreview.kt @@ -0,0 +1,197 @@ +package com.android.messaging.ui.conversation.v2.composer.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Close +import androidx.compose.material.icons.rounded.Description +import androidx.compose.material.icons.rounded.PlayArrow +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.FilledIconButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import com.android.messaging.R +import com.android.messaging.ui.conversation.v2.composer.model.ConversationComposerAttachmentUiState +import com.android.messaging.ui.conversation.v2.mediapicker.component.ConversationMediaThumbnail +import com.android.messaging.util.ContentType + +private val ATTACHMENT_PREVIEW_CORNER_RADIUS = 20.dp +private const val ATTACHMENT_PREVIEW_SIZE_PX = 256 + +@Composable +internal fun ConversationAttachmentPreview( + modifier: Modifier = Modifier, + attachments: List, + onPendingAttachmentRemove: (String) -> Unit, + onResolvedAttachmentClick: (ConversationComposerAttachmentUiState.Resolved) -> Unit, + onResolvedAttachmentRemove: (String) -> Unit, +) { + if (attachments.isEmpty()) { + return + } + + LazyRow( + modifier = modifier, + contentPadding = PaddingValues( + start = 12.dp, + top = 4.dp, + end = 12.dp, + bottom = 4.dp, + ), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + items( + items = attachments, + key = { attachment -> attachment.key }, + ) { attachment -> + when (attachment) { + is ConversationComposerAttachmentUiState.Pending -> { + PendingAttachmentPreviewItem( + onRemoveClick = { + onPendingAttachmentRemove(attachment.key) + }, + ) + } + + is ConversationComposerAttachmentUiState.Resolved -> { + ResolvedAttachmentPreviewItem( + attachment = attachment, + onAttachmentClick = { + onResolvedAttachmentClick(attachment) + }, + onRemoveClick = { + onResolvedAttachmentRemove(attachment.contentUri) + }, + ) + } + } + } + } +} + +@Composable +private fun PendingAttachmentPreviewItem( + onRemoveClick: () -> Unit, +) { + AttachmentPreviewItemContainer( + onClick = {}, + ) { + Box( + modifier = Modifier + .fillMaxSize() + .background(color = MaterialTheme.colorScheme.surfaceContainerHigh), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Rounded.Description, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + CircularProgressIndicator( + modifier = Modifier.size(28.dp), + strokeWidth = 2.dp, + ) + } + + RemoveAttachmentButton(onClick = onRemoveClick) + } +} + +@Composable +private fun ResolvedAttachmentPreviewItem( + attachment: ConversationComposerAttachmentUiState.Resolved, + onAttachmentClick: () -> Unit, + onRemoveClick: () -> Unit, +) { + val thumbnailSize = IntSize( + width = ATTACHMENT_PREVIEW_SIZE_PX, + height = ATTACHMENT_PREVIEW_SIZE_PX, + ) + + AttachmentPreviewItemContainer( + onClick = onAttachmentClick, + ) { + ConversationMediaThumbnail( + modifier = Modifier.fillMaxSize(), + contentUri = attachment.contentUri, + contentType = attachment.contentType, + size = thumbnailSize, + ) + + if (ContentType.isVideoType(attachment.contentType)) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Rounded.PlayArrow, + contentDescription = null, + modifier = Modifier.size(28.dp), + tint = MaterialTheme.colorScheme.onPrimary, + ) + } + } + + RemoveAttachmentButton(onClick = onRemoveClick) + } +} + +@Composable +private fun AttachmentPreviewItemContainer( + onClick: () -> Unit, + content: @Composable BoxScope.() -> Unit, +) { + Surface( + modifier = Modifier + .size(88.dp) + .clip(RoundedCornerShape(ATTACHMENT_PREVIEW_CORNER_RADIUS)) + .clickable(onClick = onClick), + shape = RoundedCornerShape(ATTACHMENT_PREVIEW_CORNER_RADIUS), + color = MaterialTheme.colorScheme.surfaceContainerHigh, + ) { + Box(content = content) + } +} + +@Composable +private fun BoxScope.RemoveAttachmentButton(onClick: () -> Unit) { + FilledIconButton( + modifier = Modifier + .align(Alignment.TopEnd) + .padding(6.dp) + .size(28.dp), + onClick = onClick, + colors = IconButtonDefaults.filledIconButtonColors( + containerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.9f), + contentColor = MaterialTheme.colorScheme.onSurface, + ), + ) { + Icon( + imageVector = Icons.Rounded.Close, + contentDescription = pluralStringResource( + id = R.plurals.attachment_preview_close_content_description, + count = 1, + ), + ) + } +} diff --git a/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationComposeBar.kt b/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationComposeBar.kt index 4bb5e87a..347cfddc 100644 --- a/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationComposeBar.kt +++ b/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationComposeBar.kt @@ -9,16 +9,11 @@ import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.rounded.Send import androidx.compose.material.icons.rounded.Image -import androidx.compose.material3.FilledIconButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton -import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextField @@ -28,6 +23,8 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.platform.LocalHapticFeedback @@ -43,16 +40,6 @@ import com.android.messaging.ui.conversation.v2.CONVERSATION_TEXT_FIELD_TEST_TAG import com.android.messaging.ui.conversation.v2.conversationShape import com.android.messaging.ui.core.AppTheme -private val CONVERSATION_COMPOSE_BAR_FIELD_CORNER_RADIUS = 28.dp -private val CONVERSATION_COMPOSE_BAR_FIELD_SHAPE = - RoundedCornerShape(size = CONVERSATION_COMPOSE_BAR_FIELD_CORNER_RADIUS) -private val CONVERSATION_COMPOSE_BAR_SINGLE_LINE_HEIGHT = 56.dp -private val CONVERSATION_COMPOSE_BAR_SEND_BUTTON_SIZE = CONVERSATION_COMPOSE_BAR_SINGLE_LINE_HEIGHT -private val CONVERSATION_COMPOSE_BAR_SEND_BUTTON_SPACING = 8.dp -private val CONVERSATION_COMPOSE_BAR_TEXT_FIELD_PADDING_HORIZONTAL = 12.dp -private val CONVERSATION_COMPOSE_BAR_TEXT_FIELD_PADDING_VERTICAL = 8.dp -private val CONVERSATION_COMPOSE_BAR_PREVIEW_PADDING_VERTICAL = 24.dp - @Composable internal fun ConversationComposeBar( modifier: Modifier = Modifier, @@ -60,20 +47,26 @@ internal fun ConversationComposeBar( isMessageFieldEnabled: Boolean, isAttachmentActionEnabled: Boolean, isSendActionEnabled: Boolean, + messageFieldFocusRequester: FocusRequester? = null, onAttachmentClick: () -> Unit, onMessageTextChange: (String) -> Unit, onSendClick: () -> Unit, ) { val presentation = rememberConversationComposeBarPresentation() - ConversationComposeBarContainer( - modifier = modifier, + Box( + modifier = modifier + .fillMaxWidth() + .imePadding() + .navigationBarsPadding() + .testTag(CONVERSATION_COMPOSE_BAR_TEST_TAG), ) { ConversationComposeTextField( messageText = messageText, isMessageFieldEnabled = isMessageFieldEnabled, isAttachmentActionEnabled = isAttachmentActionEnabled, isSendActionEnabled = isSendActionEnabled, + messageFieldFocusRequester = messageFieldFocusRequester, presentation = presentation, onAttachmentClick = onAttachmentClick, onMessageTextChange = onMessageTextChange, @@ -88,7 +81,7 @@ private fun rememberConversationComposeBarPresentation(): ConversationComposeBar return remember(fieldColors) { ConversationComposeBarPresentation( - fieldShape = CONVERSATION_COMPOSE_BAR_FIELD_SHAPE, + fieldShape = RoundedCornerShape(size = 28.dp), fieldColors = fieldColors, ) } @@ -118,28 +111,13 @@ private fun conversationComposeBarTextFieldColors(): TextFieldColors { ) } -@Composable -private fun ConversationComposeBarContainer( - modifier: Modifier = Modifier, - content: @Composable () -> Unit, -) { - Box( - modifier = modifier - .fillMaxWidth() - .imePadding() - .navigationBarsPadding() - .testTag(CONVERSATION_COMPOSE_BAR_TEST_TAG), - ) { - content() - } -} - @Composable private fun ConversationComposeTextField( messageText: String, isMessageFieldEnabled: Boolean, isAttachmentActionEnabled: Boolean, isSendActionEnabled: Boolean, + messageFieldFocusRequester: FocusRequester?, presentation: ConversationComposeBarPresentation, onAttachmentClick: () -> Unit, onMessageTextChange: (String) -> Unit, @@ -149,19 +127,25 @@ private fun ConversationComposeTextField( modifier = Modifier .fillMaxWidth() .padding( - horizontal = CONVERSATION_COMPOSE_BAR_TEXT_FIELD_PADDING_HORIZONTAL, - vertical = CONVERSATION_COMPOSE_BAR_TEXT_FIELD_PADDING_VERTICAL, + horizontal = 12.dp, + vertical = 8.dp, ), horizontalArrangement = Arrangement.spacedBy( - space = CONVERSATION_COMPOSE_BAR_SEND_BUTTON_SPACING + space = 8.dp, ), verticalAlignment = Alignment.Bottom, ) { TextField( modifier = Modifier .weight(weight = 1f) + .then( + when (messageFieldFocusRequester) { + null -> Modifier + else -> Modifier.focusRequester(messageFieldFocusRequester) + }, + ) .testTag(CONVERSATION_TEXT_FIELD_TEST_TAG) - .heightIn(min = CONVERSATION_COMPOSE_BAR_SINGLE_LINE_HEIGHT), + .heightIn(min = 56.dp), value = messageText, onValueChange = onMessageTextChange, enabled = isMessageFieldEnabled, @@ -181,6 +165,11 @@ private fun ConversationComposeTextField( ) ConversationComposeSendAction( + modifier = Modifier + .testTag(CONVERSATION_SEND_BUTTON_TEST_TAG) + .semantics { + conversationShape = CONVERSATION_SEND_BUTTON_SHAPE_CIRCLE + }, enabled = isSendActionEnabled, onClick = onSendClick, ) @@ -218,36 +207,15 @@ private fun ConversationComposeImageAction( @Composable private fun ConversationComposeSendAction( + modifier: Modifier = Modifier, enabled: Boolean, onClick: () -> Unit, ) { - val hapticFeedback = LocalHapticFeedback.current - - FilledIconButton( - modifier = Modifier - .testTag(CONVERSATION_SEND_BUTTON_TEST_TAG) - .semantics { - conversationShape = CONVERSATION_SEND_BUTTON_SHAPE_CIRCLE - } - .size(size = CONVERSATION_COMPOSE_BAR_SEND_BUTTON_SIZE), - onClick = { - hapticFeedback.performHapticFeedback(HapticFeedbackType.ContextClick) - onClick() - }, + ConversationSendActionButton( + modifier = modifier, enabled = enabled, - shape = CircleShape, - colors = IconButtonDefaults.filledIconButtonColors( - containerColor = MaterialTheme.colorScheme.primary, - contentColor = MaterialTheme.colorScheme.onPrimary, - disabledContainerColor = MaterialTheme.colorScheme.surfaceContainerHighest, - disabledContentColor = MaterialTheme.colorScheme.onSurfaceVariant, - ), - ) { - Icon( - imageVector = Icons.AutoMirrored.Rounded.Send, - contentDescription = stringResource(id = R.string.sendButtonContentDescription), - ) - } + onClick = onClick, + ) } private data class ConversationComposeBarPresentation( @@ -263,7 +231,7 @@ private fun ConversationComposeBarPreviewContainer( Box( modifier = Modifier .background(color = MaterialTheme.colorScheme.background) - .padding(vertical = CONVERSATION_COMPOSE_BAR_PREVIEW_PADDING_VERTICAL), + .padding(vertical = 24.dp), ) { content() } diff --git a/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationComposerSection.kt b/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationComposerSection.kt new file mode 100644 index 00000000..7f7f0f77 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationComposerSection.kt @@ -0,0 +1,46 @@ +package com.android.messaging.ui.conversation.v2.composer.ui + +import androidx.compose.foundation.layout.Column +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import com.android.messaging.ui.conversation.v2.composer.model.ConversationComposerAttachmentUiState + +@Composable +internal fun ConversationComposerSection( + modifier: Modifier = Modifier, + attachments: List, + messageText: String, + isMessageFieldEnabled: Boolean, + isAttachmentActionEnabled: Boolean, + isSendActionEnabled: Boolean, + messageFieldFocusRequester: FocusRequester, + onAttachmentClick: () -> Unit, + onMessageTextChange: (String) -> Unit, + onPendingAttachmentRemove: (String) -> Unit, + onResolvedAttachmentClick: (ConversationComposerAttachmentUiState.Resolved) -> Unit, + onResolvedAttachmentRemove: (String) -> Unit, + onSendClick: () -> Unit, +) { + Column( + modifier = modifier, + ) { + ConversationAttachmentPreview( + attachments = attachments, + onPendingAttachmentRemove = onPendingAttachmentRemove, + onResolvedAttachmentClick = onResolvedAttachmentClick, + onResolvedAttachmentRemove = onResolvedAttachmentRemove, + ) + + ConversationComposeBar( + messageText = messageText, + isMessageFieldEnabled = isMessageFieldEnabled, + isAttachmentActionEnabled = isAttachmentActionEnabled, + isSendActionEnabled = isSendActionEnabled, + messageFieldFocusRequester = messageFieldFocusRequester, + onAttachmentClick = onAttachmentClick, + onMessageTextChange = onMessageTextChange, + onSendClick = onSendClick, + ) + } +} diff --git a/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationSendActionButton.kt b/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationSendActionButton.kt new file mode 100644 index 00000000..0725b678 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationSendActionButton.kt @@ -0,0 +1,48 @@ +package com.android.messaging.ui.conversation.v2.composer.ui + +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.Send +import androidx.compose.material3.FilledIconButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.android.messaging.R + +@Composable +internal fun ConversationSendActionButton( + modifier: Modifier = Modifier, + enabled: Boolean, + onClick: () -> Unit, +) { + val hapticFeedback = LocalHapticFeedback.current + + FilledIconButton( + modifier = modifier + .size(size = 56.dp), + onClick = { + hapticFeedback.performHapticFeedback(HapticFeedbackType.ContextClick) + onClick() + }, + enabled = enabled, + shape = CircleShape, + colors = IconButtonDefaults.filledIconButtonColors( + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary, + disabledContainerColor = MaterialTheme.colorScheme.surfaceContainerHighest, + disabledContentColor = MaterialTheme.colorScheme.onSurfaceVariant, + ), + ) { + Icon( + imageVector = Icons.AutoMirrored.Rounded.Send, + contentDescription = stringResource(id = R.string.sendButtonContentDescription), + ) + } +} From 53711095a8dcc00dbf97242142b1600ed74c582d Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Thu, 2 Apr 2026 13:46:21 +0300 Subject: [PATCH 18/99] Add conversation media picker implementation --- res/values/strings.xml | 15 + .../data/media/model/ConversationMediaItem.kt | 16 + .../repository/ConversationMediaRepository.kt | 140 ++++ .../conversation/ConversationBindsModule.kt | 32 +- .../ConversationViewModelBindsModule.kt | 44 + .../ConversationAttachmentBridge.kt | 82 ++ .../v2/mediapicker/ConversationMediaPicker.kt | 119 +++ .../ConversationMediaPickerCaptureRoute.kt | 83 ++ .../ConversationMediaPickerDelegate.kt | 171 ++++ .../ConversationMediaPickerEffects.kt | 35 + .../ConversationMediaPickerOverlay.kt | 129 +++ .../ConversationMediaPickerPermission.kt | 133 +++ .../ConversationMediaPickerScaffold.kt | 331 ++++++++ .../ConversationMediaPickerState.kt | 133 +++ .../camera/ConversationCameraController.kt | 774 ++++++++++++++++++ .../camera/ConversationCameraEffects.kt | 39 + .../camera/ConversationMediaPickerActions.kt | 69 ++ .../camera/ConversationPhotoFlashMode.kt | 26 + .../v2/mediapicker/camera/Exceptions.kt | 74 ++ .../ConversationMediaPickerShared.kt | 149 ++++ .../component/ConversationMediaThumbnail.kt | 574 +++++++++++++ .../ConversationMediaCaptureControls.kt | 237 ++++++ .../ConversationMediaCaptureShutterButton.kt | 471 +++++++++++ .../capture/ConversationMediaPickerCapture.kt | 172 ++++ .../gallery/ConversationMediaPickerGallery.kt | 235 ++++++ .../review/ConversationMediaPickerReview.kt | 355 ++++++++ .../ConversationMediaReviewBackground.kt | 213 +++++ .../ConversationMediaReviewBitmapCache.kt | 29 + .../review/ConversationMediaReviewPageCard.kt | 331 ++++++++ .../ConversationMediaReviewPagerState.kt | 177 ++++ .../model/ConversationCapturedMedia.kt | 8 + .../model/ConversationMediaPickerUiState.kt | 12 + 32 files changed, 5389 insertions(+), 19 deletions(-) create mode 100644 src/com/android/messaging/data/media/model/ConversationMediaItem.kt create mode 100644 src/com/android/messaging/data/media/repository/ConversationMediaRepository.kt create mode 100644 src/com/android/messaging/di/conversation/ConversationViewModelBindsModule.kt create mode 100644 src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationAttachmentBridge.kt create mode 100644 src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationMediaPicker.kt create mode 100644 src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationMediaPickerCaptureRoute.kt create mode 100644 src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationMediaPickerDelegate.kt create mode 100644 src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationMediaPickerEffects.kt create mode 100644 src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationMediaPickerOverlay.kt create mode 100644 src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationMediaPickerPermission.kt create mode 100644 src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationMediaPickerScaffold.kt create mode 100644 src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationMediaPickerState.kt create mode 100644 src/com/android/messaging/ui/conversation/v2/mediapicker/camera/ConversationCameraController.kt create mode 100644 src/com/android/messaging/ui/conversation/v2/mediapicker/camera/ConversationCameraEffects.kt create mode 100644 src/com/android/messaging/ui/conversation/v2/mediapicker/camera/ConversationMediaPickerActions.kt create mode 100644 src/com/android/messaging/ui/conversation/v2/mediapicker/camera/ConversationPhotoFlashMode.kt create mode 100644 src/com/android/messaging/ui/conversation/v2/mediapicker/camera/Exceptions.kt create mode 100644 src/com/android/messaging/ui/conversation/v2/mediapicker/component/ConversationMediaPickerShared.kt create mode 100644 src/com/android/messaging/ui/conversation/v2/mediapicker/component/ConversationMediaThumbnail.kt create mode 100644 src/com/android/messaging/ui/conversation/v2/mediapicker/component/capture/ConversationMediaCaptureControls.kt create mode 100644 src/com/android/messaging/ui/conversation/v2/mediapicker/component/capture/ConversationMediaCaptureShutterButton.kt create mode 100644 src/com/android/messaging/ui/conversation/v2/mediapicker/component/capture/ConversationMediaPickerCapture.kt create mode 100644 src/com/android/messaging/ui/conversation/v2/mediapicker/component/gallery/ConversationMediaPickerGallery.kt create mode 100644 src/com/android/messaging/ui/conversation/v2/mediapicker/component/review/ConversationMediaPickerReview.kt create mode 100644 src/com/android/messaging/ui/conversation/v2/mediapicker/component/review/ConversationMediaReviewBackground.kt create mode 100644 src/com/android/messaging/ui/conversation/v2/mediapicker/component/review/ConversationMediaReviewBitmapCache.kt create mode 100644 src/com/android/messaging/ui/conversation/v2/mediapicker/component/review/ConversationMediaReviewPageCard.kt create mode 100644 src/com/android/messaging/ui/conversation/v2/mediapicker/component/review/ConversationMediaReviewPagerState.kt create mode 100644 src/com/android/messaging/ui/conversation/v2/mediapicker/model/ConversationCapturedMedia.kt create mode 100644 src/com/android/messaging/ui/conversation/v2/mediapicker/model/ConversationMediaPickerUiState.kt diff --git a/res/values/strings.xml b/res/values/strings.xml index d2e245e5..e501415b 100644 --- a/res/values/strings.xml +++ b/res/values/strings.xml @@ -63,6 +63,21 @@ image Record audio Choose a contact + Add (%1$d) + Allow camera + Allow gallery + Allow microphone + Add another photo or video + Camera access is needed to capture photos and videos here. + Write a caption + Close media picker + Gallery access is needed to browse recent media in the conversation picker. + Microphone access is needed to record video with sound. + Photo + Remove attachment + Retake capture + Change flash mode + Video Click to open contacts list on this device diff --git a/src/com/android/messaging/data/media/model/ConversationMediaItem.kt b/src/com/android/messaging/data/media/model/ConversationMediaItem.kt new file mode 100644 index 00000000..83cdb675 --- /dev/null +++ b/src/com/android/messaging/data/media/model/ConversationMediaItem.kt @@ -0,0 +1,16 @@ +package com.android.messaging.data.media.model + +internal data class ConversationMediaItem( + val mediaId: String, + val contentUri: String, + val contentType: String, + val mediaType: ConversationMediaType, + val width: Int?, + val height: Int?, + val durationMillis: Long?, +) + +internal enum class ConversationMediaType { + Image, + Video, +} diff --git a/src/com/android/messaging/data/media/repository/ConversationMediaRepository.kt b/src/com/android/messaging/data/media/repository/ConversationMediaRepository.kt new file mode 100644 index 00000000..86403a70 --- /dev/null +++ b/src/com/android/messaging/data/media/repository/ConversationMediaRepository.kt @@ -0,0 +1,140 @@ +package com.android.messaging.data.media.repository + +import android.content.ContentResolver +import android.os.Bundle +import android.provider.MediaStore +import com.android.messaging.data.media.model.ConversationMediaItem +import com.android.messaging.data.media.model.ConversationMediaType +import com.android.messaging.di.core.IoDispatcher +import com.android.messaging.util.ContentType +import com.android.messaging.util.UriUtil +import com.android.messaging.util.core.extension.typedFlow +import javax.inject.Inject +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOn + +internal interface ConversationMediaRepository { + fun getRecentMedia(limit: Int = DEFAULT_RECENT_MEDIA_LIMIT): Flow> + + private companion object { + private const val DEFAULT_RECENT_MEDIA_LIMIT = 200 + } +} + +internal class ConversationMediaRepositoryImpl @Inject constructor( + private val contentResolver: ContentResolver, + @param:IoDispatcher + private val ioDispatcher: CoroutineDispatcher, +) : ConversationMediaRepository { + + override fun getRecentMedia(limit: Int): Flow> { + return typedFlow { + queryRecentMedia(limit = limit) + }.flowOn(context = ioDispatcher) + } + + private fun queryRecentMedia(limit: Int): List { + return contentResolver.query( + MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL), + RECENT_MEDIA_PROJECTION, + createRecentMediaQueryArgs(limit = limit), + null, + )?.use { cursor -> + val idIndex = cursor.getColumnIndexOrThrow(MediaStore.Files.FileColumns._ID) + val mediaTypeIndex = cursor.getColumnIndexOrThrow( + MediaStore.Files.FileColumns.MEDIA_TYPE, + ) + val mimeTypeIndex = cursor.getColumnIndexOrThrow( + MediaStore.Files.FileColumns.MIME_TYPE, + ) + val widthIndex = cursor.getColumnIndexOrThrow(MediaStore.Files.FileColumns.WIDTH) + val heightIndex = cursor.getColumnIndexOrThrow( + MediaStore.Files.FileColumns.HEIGHT, + ) + val durationIndex = cursor.getColumnIndexOrThrow( + MediaStore.Video.VideoColumns.DURATION, + ) + + buildList(capacity = cursor.count) { + while (cursor.moveToNext()) { + val mediaStoreId = cursor.getLong(idIndex) + val mediaTypeValue = cursor.getInt(mediaTypeIndex) + val mediaType = getMediaType(mediaTypeValue = mediaTypeValue) + + val item = ConversationMediaItem( + mediaId = mediaStoreId.toString(), + contentUri = UriUtil + .getContentUriForMediaStoreId(mediaStoreId) + .toString(), + contentType = cursor + .getString(mimeTypeIndex) + ?.takeIf { it.isNotBlank() } + ?: fallbackContentType(mediaTypeValue = mediaTypeValue), + mediaType = mediaType, + width = cursor.getInt(widthIndex).takeIf { it > 0 }, + height = cursor.getInt(heightIndex).takeIf { it > 0 }, + durationMillis = cursor.getLong(durationIndex).takeIf { it > 0 }, + ) + + add(item) + } + } + } ?: emptyList() + } + + private fun createRecentMediaQueryArgs(limit: Int): Bundle { + return Bundle().apply { + putString( + ContentResolver.QUERY_ARG_SQL_SELECTION, + RECENT_MEDIA_SELECTION, + ) + putStringArray( + ContentResolver.QUERY_ARG_SORT_COLUMNS, + arrayOf(MediaStore.Files.FileColumns.DATE_ADDED), + ) + putInt( + ContentResolver.QUERY_ARG_SORT_DIRECTION, + ContentResolver.QUERY_SORT_DIRECTION_DESCENDING, + ) + putInt(ContentResolver.QUERY_ARG_LIMIT, limit) + } + } + + private fun getMediaType(mediaTypeValue: Int): ConversationMediaType { + return when (mediaTypeValue) { + MediaStore.Files.FileColumns.MEDIA_TYPE_VIDEO -> ConversationMediaType.Video + else -> ConversationMediaType.Image + } + } + + private fun fallbackContentType(mediaTypeValue: Int): String { + return when (mediaTypeValue) { + MediaStore.Files.FileColumns.MEDIA_TYPE_VIDEO -> ContentType.VIDEO_UNSPECIFIED + else -> ContentType.IMAGE_UNSPECIFIED + } + } + + private companion object { + private val RECENT_MEDIA_PROJECTION: Array = arrayOf( + MediaStore.Files.FileColumns._ID, + MediaStore.Files.FileColumns.MEDIA_TYPE, + MediaStore.Files.FileColumns.MIME_TYPE, + MediaStore.Files.FileColumns.DATE_ADDED, + MediaStore.Files.FileColumns.WIDTH, + MediaStore.Files.FileColumns.HEIGHT, + MediaStore.Video.VideoColumns.DURATION, + ) + + private val RECENT_MEDIA_SELECTION: String by lazy { + buildString { + append(MediaStore.Files.FileColumns.MEDIA_TYPE) + append(" IN (") + append(MediaStore.Files.FileColumns.MEDIA_TYPE_IMAGE) + append(",") + append(MediaStore.Files.FileColumns.MEDIA_TYPE_VIDEO) + append(")") + } + } + } +} diff --git a/src/com/android/messaging/di/conversation/ConversationBindsModule.kt b/src/com/android/messaging/di/conversation/ConversationBindsModule.kt index 8d0698a3..8de30e74 100644 --- a/src/com/android/messaging/di/conversation/ConversationBindsModule.kt +++ b/src/com/android/messaging/di/conversation/ConversationBindsModule.kt @@ -10,18 +10,16 @@ import com.android.messaging.data.conversation.repository.ConversationMetadataNo import com.android.messaging.data.conversation.repository.ConversationMetadataNotifierImpl import com.android.messaging.data.conversation.repository.ConversationsRepository import com.android.messaging.data.conversation.repository.ConversationsRepositoryImpl +import com.android.messaging.data.media.repository.ConversationMediaRepository +import com.android.messaging.data.media.repository.ConversationMediaRepositoryImpl import com.android.messaging.domain.conversation.usecase.SendConversationDraft import com.android.messaging.domain.conversation.usecase.SendConversationDraftImpl -import com.android.messaging.ui.conversation.v2.composer.delegate.ConversationDraftDelegate -import com.android.messaging.ui.conversation.v2.composer.delegate.ConversationDraftDelegateImpl import com.android.messaging.ui.conversation.v2.composer.mapper.ConversationComposerUiStateMapper import com.android.messaging.ui.conversation.v2.composer.mapper.ConversationComposerUiStateMapperImpl -import com.android.messaging.ui.conversation.v2.messages.delegate.ConversationMessagesDelegate -import com.android.messaging.ui.conversation.v2.messages.delegate.ConversationMessagesDelegateImpl +import com.android.messaging.ui.conversation.v2.mediapicker.ConversationAttachmentBridge +import com.android.messaging.ui.conversation.v2.mediapicker.ConversationAttachmentBridgeImpl import com.android.messaging.ui.conversation.v2.messages.mapper.ConversationMessageUiModelMapper import com.android.messaging.ui.conversation.v2.messages.mapper.ConversationMessageUiModelMapperImpl -import com.android.messaging.ui.conversation.v2.metadata.delegate.ConversationMetadataDelegate -import com.android.messaging.ui.conversation.v2.metadata.delegate.ConversationMetadataDelegateImpl import com.android.messaging.ui.conversation.v2.metadata.mapper.ConversationMetadataUiStateMapper import com.android.messaging.ui.conversation.v2.metadata.mapper.ConversationMetadataUiStateMapperImpl import dagger.Binds @@ -65,19 +63,9 @@ internal abstract class ConversationBindsModule { ): ConversationsRepository @Binds - abstract fun bindConversationDraftDelegate( - impl: ConversationDraftDelegateImpl, - ): ConversationDraftDelegate - - @Binds - abstract fun bindConversationMessagesDelegate( - impl: ConversationMessagesDelegateImpl, - ): ConversationMessagesDelegate - - @Binds - abstract fun bindConversationMetadataDelegate( - impl: ConversationMetadataDelegateImpl, - ): ConversationMetadataDelegate + abstract fun bindConversationAttachmentBridge( + impl: ConversationAttachmentBridgeImpl, + ): ConversationAttachmentBridge @Binds abstract fun bindConversationComposerUiStateMapper( @@ -89,6 +77,12 @@ internal abstract class ConversationBindsModule { impl: ConversationMessageUiModelMapperImpl, ): ConversationMessageUiModelMapper + @Binds + @Reusable + abstract fun bindConversationMediaRepository( + impl: ConversationMediaRepositoryImpl, + ): ConversationMediaRepository + @Binds abstract fun bindConversationMetadataUiStateMapper( impl: ConversationMetadataUiStateMapperImpl, diff --git a/src/com/android/messaging/di/conversation/ConversationViewModelBindsModule.kt b/src/com/android/messaging/di/conversation/ConversationViewModelBindsModule.kt new file mode 100644 index 00000000..1e362bbb --- /dev/null +++ b/src/com/android/messaging/di/conversation/ConversationViewModelBindsModule.kt @@ -0,0 +1,44 @@ +package com.android.messaging.di.conversation + +import com.android.messaging.ui.conversation.v2.composer.delegate.ConversationDraftDelegate +import com.android.messaging.ui.conversation.v2.composer.delegate.ConversationDraftDelegateImpl +import com.android.messaging.ui.conversation.v2.mediapicker.ConversationMediaPickerDelegate +import com.android.messaging.ui.conversation.v2.mediapicker.ConversationMediaPickerDelegateImpl +import com.android.messaging.ui.conversation.v2.messages.delegate.ConversationMessagesDelegate +import com.android.messaging.ui.conversation.v2.messages.delegate.ConversationMessagesDelegateImpl +import com.android.messaging.ui.conversation.v2.metadata.delegate.ConversationMetadataDelegate +import com.android.messaging.ui.conversation.v2.metadata.delegate.ConversationMetadataDelegateImpl +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ViewModelComponent +import dagger.hilt.android.scopes.ViewModelScoped + +@Module +@InstallIn(ViewModelComponent::class) +internal abstract class ConversationViewModelBindsModule { + + @Binds + @ViewModelScoped + abstract fun bindConversationDraftDelegate( + impl: ConversationDraftDelegateImpl, + ): ConversationDraftDelegate + + @Binds + @ViewModelScoped + abstract fun bindConversationMediaPickerDelegate( + impl: ConversationMediaPickerDelegateImpl, + ): ConversationMediaPickerDelegate + + @Binds + @ViewModelScoped + abstract fun bindConversationMessagesDelegate( + impl: ConversationMessagesDelegateImpl, + ): ConversationMessagesDelegate + + @Binds + @ViewModelScoped + abstract fun bindConversationMetadataDelegate( + impl: ConversationMetadataDelegateImpl, + ): ConversationMetadataDelegate +} diff --git a/src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationAttachmentBridge.kt b/src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationAttachmentBridge.kt new file mode 100644 index 00000000..531dfd30 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationAttachmentBridge.kt @@ -0,0 +1,82 @@ +package com.android.messaging.ui.conversation.v2.mediapicker + +import android.content.ContentResolver +import androidx.core.net.toUri +import com.android.messaging.data.conversation.model.draft.ConversationDraftAttachment +import com.android.messaging.data.media.model.ConversationMediaItem +import com.android.messaging.datamodel.MediaScratchFileProvider +import com.android.messaging.di.core.IoDispatcher +import com.android.messaging.ui.conversation.v2.mediapicker.model.ConversationCapturedMedia +import com.android.messaging.util.LogUtil +import com.android.messaging.util.core.extension.unitFlow +import javax.inject.Inject +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.flowOn + +internal interface ConversationAttachmentBridge { + fun createDraftAttachments( + mediaItems: Collection, + ): List + + fun createDraftAttachment( + capturedMedia: ConversationCapturedMedia, + ): ConversationDraftAttachment + + fun deleteTemporaryAttachment( + contentUri: String, + ): Flow +} + +internal class ConversationAttachmentBridgeImpl @Inject constructor( + private val contentResolver: ContentResolver, + @param:IoDispatcher + private val ioDispatcher: CoroutineDispatcher, +) : ConversationAttachmentBridge { + + override fun createDraftAttachments( + mediaItems: Collection, + ): List { + return mediaItems.map { mediaItem -> + ConversationDraftAttachment( + contentType = mediaItem.contentType, + contentUri = mediaItem.contentUri, + width = mediaItem.width, + height = mediaItem.height, + ) + } + } + + override fun createDraftAttachment( + capturedMedia: ConversationCapturedMedia, + ): ConversationDraftAttachment { + return ConversationDraftAttachment( + contentType = capturedMedia.contentType, + contentUri = capturedMedia.contentUri, + width = capturedMedia.width, + height = capturedMedia.height, + ) + } + + override fun deleteTemporaryAttachment(contentUri: String): Flow { + return unitFlow { + val attachmentUri = contentUri.toUri() + if (MediaScratchFileProvider.isMediaScratchSpaceUri(attachmentUri)) { + contentResolver.delete(attachmentUri, null, null) + } + }.catch { throwable -> + if (throwable is CancellationException) { + throw throwable + } + + LogUtil.w(TAG, "Failed to delete temporary attachment $contentUri", throwable) + emit(Unit) + }.flowOn(ioDispatcher) + } + + private companion object { + private const val TAG = "ConversationAttachmentBridge" + } +} diff --git a/src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationMediaPicker.kt b/src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationMediaPicker.kt new file mode 100644 index 00000000..68cabfde --- /dev/null +++ b/src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationMediaPicker.kt @@ -0,0 +1,119 @@ +package com.android.messaging.ui.conversation.v2.mediapicker + +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.SheetValue +import androidx.compose.material3.rememberBottomSheetScaffoldState +import androidx.compose.material3.rememberStandardBottomSheetState +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.Modifier +import androidx.lifecycle.compose.LocalLifecycleOwner +import com.android.messaging.data.media.model.ConversationMediaItem +import com.android.messaging.ui.conversation.v2.composer.model.ConversationComposerAttachmentUiState +import com.android.messaging.ui.conversation.v2.mediapicker.camera.BindConversationCameraLifecycleEffect +import com.android.messaging.ui.conversation.v2.mediapicker.camera.rememberConversationCameraController +import com.android.messaging.ui.conversation.v2.mediapicker.model.ConversationCapturedMedia +import com.android.messaging.ui.conversation.v2.mediapicker.model.ConversationMediaPickerUiState +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun ConversationMediaPicker( + modifier: Modifier = Modifier, + uiState: ConversationMediaPickerUiState, + attachments: ImmutableList, + conversationTitle: String?, + isSendActionEnabled: Boolean, + state: ConversationMediaPickerState, + cameraPermissionGranted: Boolean, + audioPermissionGranted: Boolean, + galleryPermissionGranted: Boolean, + onClose: () -> Unit, + onAttachmentPreviewClick: (ConversationComposerAttachmentUiState.Resolved) -> Unit, + onAttachmentCaptionChange: (String, String) -> Unit, + onAttachmentRemove: (String) -> Unit, + onGalleryMediaConfirmed: (List) -> Unit, + onRequestAudioPermission: () -> Unit, + onRequestCameraPermission: () -> Unit, + onRequestGalleryPermission: () -> Unit, + onCapturedMediaReady: (ConversationCapturedMedia) -> Unit, + onSendClick: () -> Unit, +) { + val cameraController = rememberConversationCameraController() + val lifecycleOwner = LocalLifecycleOwner.current + + val resolvedAttachments = remember(attachments) { + attachments + .asSequence() + .filterIsInstance() + .toImmutableList() + } + + val isReviewVisible = state.isReviewRequested && resolvedAttachments.isNotEmpty() + val sheetState = rememberStandardBottomSheetState( + initialValue = SheetValue.PartiallyExpanded, + skipHiddenState = true, + ) + + val scaffoldState = rememberBottomSheetScaffoldState( + bottomSheetState = sheetState, + ) + + var pendingSelectedMediaItem by remember { + mutableStateOf(value = null) + } + + HandlePendingGallerySelectionEffect( + pendingSelectedMediaItem = pendingSelectedMediaItem, + sheetState = sheetState, + onGalleryMediaConfirmed = onGalleryMediaConfirmed, + onShowReview = state::showReview, + onSelectionHandled = { + @Suppress("AssignedValueIsNeverRead") + pendingSelectedMediaItem = null + }, + ) + BindConversationCameraLifecycleEffect( + cameraController = cameraController, + cameraPermissionGranted = cameraPermissionGranted, + isCameraPreviewVisible = !isReviewVisible, + lifecycleOwner = lifecycleOwner, + ) + + ConversationMediaPickerScaffold( + modifier = modifier, + cameraController = cameraController, + scaffoldState = scaffoldState, + uiState = uiState, + resolvedAttachments = resolvedAttachments, + conversationTitle = conversationTitle, + captureMode = state.captureMode, + reviewContentUri = state.reviewContentUri, + reviewRequestSequence = state.reviewRequestSequence, + isReviewVisible = isReviewVisible, + isSendActionEnabled = isSendActionEnabled, + cameraPermissionGranted = cameraPermissionGranted, + audioPermissionGranted = audioPermissionGranted, + galleryPermissionGranted = galleryPermissionGranted, + onClose = onClose, + onAttachmentPreviewClick = onAttachmentPreviewClick, + onAttachmentCaptionChange = onAttachmentCaptionChange, + onAttachmentRemove = onAttachmentRemove, + onGalleryMediaClick = { mediaItem -> + @Suppress("AssignedValueIsNeverRead") + pendingSelectedMediaItem = mediaItem + }, + onRequestAudioPermission = onRequestAudioPermission, + onRequestCameraPermission = onRequestCameraPermission, + onRequestGalleryPermission = onRequestGalleryPermission, + onCapturedMediaReady = onCapturedMediaReady, + onSendClick = onSendClick, + onShowReview = state::showReview, + onClearReview = state::clearReview, + onCaptureModeChange = state::updateCaptureMode, + ) +} diff --git a/src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationMediaPickerCaptureRoute.kt b/src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationMediaPickerCaptureRoute.kt new file mode 100644 index 00000000..73817fb3 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationMediaPickerCaptureRoute.kt @@ -0,0 +1,83 @@ +package com.android.messaging.ui.conversation.v2.mediapicker + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.android.messaging.ui.conversation.v2.mediapicker.camera.ConversationCameraController +import com.android.messaging.ui.conversation.v2.mediapicker.camera.handlePhotoCaptureRequest +import com.android.messaging.ui.conversation.v2.mediapicker.camera.handleSwitchCameraRequest +import com.android.messaging.ui.conversation.v2.mediapicker.camera.handleToggleFlashRequest +import com.android.messaging.ui.conversation.v2.mediapicker.camera.handleVideoCaptureRequest +import com.android.messaging.ui.conversation.v2.mediapicker.component.capture.ConversationMediaCaptureContent +import com.android.messaging.ui.conversation.v2.mediapicker.model.ConversationCapturedMedia + +@Composable +internal fun ConversationMediaCaptureRoute( + modifier: Modifier = Modifier, + cameraController: ConversationCameraController, + audioPermissionGranted: Boolean, + captureMode: ConversationCaptureMode, + onClose: () -> Unit, + onRequestAudioPermission: () -> Unit, + onShowReview: (String) -> Unit, + onCapturedMediaReady: (ConversationCapturedMedia) -> Unit, + onCaptureModeChange: (ConversationCaptureMode) -> Unit, +) { + val hasFlashUnit = cameraController.hasFlashUnit.collectAsStateWithLifecycle() + val isPhotoCaptureInProgress = cameraController.isPhotoCaptureInProgress + .collectAsStateWithLifecycle() + + val isRecording = cameraController.isRecording.collectAsStateWithLifecycle() + val photoFlashMode = cameraController.photoFlashMode.collectAsStateWithLifecycle() + val recordingDurationMillis = cameraController.recordingDurationMillis + .collectAsStateWithLifecycle() + + ConversationMediaCaptureContent( + modifier = modifier, + audioPermissionGranted = audioPermissionGranted, + captureMode = captureMode, + hasFlashUnit = hasFlashUnit.value, + isPhotoCaptureInProgress = isPhotoCaptureInProgress.value, + isRecording = isRecording.value, + photoFlashMode = photoFlashMode.value, + onCloseClick = { + if (isRecording.value) { + cameraController.cancelVideoRecording() + } + onClose() + }, + onRequestAudioPermission = onRequestAudioPermission, + onPhotoCaptureClick = { + handlePhotoCaptureRequest( + cameraController = cameraController, + onCapturedMediaReady = onCapturedMediaReady, + onShowReview = onShowReview, + ) + }, + onPhotoModeClick = { + onCaptureModeChange(ConversationCaptureMode.Photo) + }, + onSwitchCameraClick = { + handleSwitchCameraRequest( + cameraController = cameraController, + ) + }, + onToggleFlashClick = { + handleToggleFlashRequest( + cameraController = cameraController, + ) + }, + onVideoCaptureClick = { + handleVideoCaptureRequest( + cameraController = cameraController, + isRecording = isRecording.value, + onCapturedMediaReady = onCapturedMediaReady, + onShowReview = onShowReview, + ) + }, + onVideoModeClick = { + onCaptureModeChange(ConversationCaptureMode.Video) + }, + recordingDurationMillis = recordingDurationMillis.value, + ) +} diff --git a/src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationMediaPickerDelegate.kt b/src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationMediaPickerDelegate.kt new file mode 100644 index 00000000..c0fa5f7e --- /dev/null +++ b/src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationMediaPickerDelegate.kt @@ -0,0 +1,171 @@ +package com.android.messaging.ui.conversation.v2.mediapicker + +import com.android.messaging.data.media.model.ConversationMediaItem +import com.android.messaging.data.media.repository.ConversationMediaRepository +import com.android.messaging.di.core.DefaultDispatcher +import com.android.messaging.ui.conversation.v2.common.ConversationScreenDelegate +import com.android.messaging.ui.conversation.v2.composer.delegate.ConversationDraftDelegate +import com.android.messaging.ui.conversation.v2.mediapicker.model.ConversationCapturedMedia +import com.android.messaging.ui.conversation.v2.mediapicker.model.ConversationMediaPickerUiState +import com.android.messaging.ui.conversation.v2.screen.model.ConversationScreenEffect +import com.android.messaging.util.LogUtil +import javax.inject.Inject +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +internal interface ConversationMediaPickerDelegate : + ConversationScreenDelegate { + val effects: Flow + + fun onGalleryMediaConfirmed(mediaItems: List) + + fun onGalleryVisibilityChanged(isVisible: Boolean) + + fun onCapturedMediaReady(capturedMedia: ConversationCapturedMedia) + + fun onRemovePendingAttachment(pendingAttachmentId: String) + + fun onRemoveResolvedAttachment(contentUri: String) + + fun onScreenCleared() +} + +internal class ConversationMediaPickerDelegateImpl @Inject constructor( + private val conversationDraftDelegate: ConversationDraftDelegate, + private val conversationAttachmentBridge: ConversationAttachmentBridge, + private val conversationMediaRepository: ConversationMediaRepository, + @param:DefaultDispatcher + private val defaultDispatcher: CoroutineDispatcher, +) : ConversationMediaPickerDelegate { + + private val _effects = MutableSharedFlow( + extraBufferCapacity = 1, + ) + private val _state = MutableStateFlow(ConversationMediaPickerUiState()) + private val pendingAttachmentJobs = mutableMapOf() + + override val effects = _effects.asSharedFlow() + override val state = _state.asStateFlow() + + private var boundScope: CoroutineScope? = null + + override fun bind( + scope: CoroutineScope, + conversationIdFlow: StateFlow, + ) { + if (boundScope != null) { + return + } + + boundScope = scope + + scope.launch(defaultDispatcher) { + conversationIdFlow + .collect { + cancelPendingAttachmentJobs() + } + } + } + + override fun onGalleryMediaConfirmed(mediaItems: List) { + if (mediaItems.isEmpty()) { + return + } + + conversationDraftDelegate.addAttachments( + attachments = conversationAttachmentBridge.createDraftAttachments( + mediaItems = mediaItems, + ), + ) + } + + override fun onGalleryVisibilityChanged(isVisible: Boolean) { + if (!isVisible) { + return + } + + if (state.value.isLoadingGallery || state.value.galleryItems.isNotEmpty()) { + return + } + + boundScope?.launch(defaultDispatcher) { + _state.update { currentMediaPickerUiState -> + currentMediaPickerUiState.copy(isLoadingGallery = true) + } + + conversationMediaRepository + .getRecentMedia() + .map { it.toImmutableList() } + .catch { throwable -> + LogUtil.w(TAG, "Unable to query gallery items", throwable) + + _state.update { currentMediaPickerUiState -> + currentMediaPickerUiState.copy( + isLoadingGallery = false, + ) + } + } + .collect { galleryItems -> + _state.update { currentMediaPickerUiState -> + currentMediaPickerUiState.copy( + galleryItems = galleryItems, + isLoadingGallery = false, + ) + } + } + } + } + + override fun onCapturedMediaReady(capturedMedia: ConversationCapturedMedia) { + conversationDraftDelegate.addAttachments( + attachments = listOf( + conversationAttachmentBridge.createDraftAttachment( + capturedMedia = capturedMedia, + ), + ), + ) + } + + override fun onRemovePendingAttachment(pendingAttachmentId: String) { + pendingAttachmentJobs.remove(pendingAttachmentId)?.cancel() + conversationDraftDelegate.removePendingAttachment( + pendingAttachmentId = pendingAttachmentId, + ) + } + + override fun onRemoveResolvedAttachment(contentUri: String) { + conversationDraftDelegate.removeAttachment(contentUri = contentUri) + + boundScope?.launch(defaultDispatcher) { + conversationAttachmentBridge + .deleteTemporaryAttachment(contentUri = contentUri) + .collect() + } + } + + override fun onScreenCleared() { + cancelPendingAttachmentJobs() + } + + private fun cancelPendingAttachmentJobs() { + pendingAttachmentJobs.values.forEach { it.cancel() } + pendingAttachmentJobs.clear() + } + + private companion object { + private const val TAG = "ConversationMediaPickerDelegate" + } +} diff --git a/src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationMediaPickerEffects.kt b/src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationMediaPickerEffects.kt new file mode 100644 index 00000000..13b33c03 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationMediaPickerEffects.kt @@ -0,0 +1,35 @@ +package com.android.messaging.ui.conversation.v2.mediapicker + +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.SheetState +import androidx.compose.material3.SheetValue +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import com.android.messaging.data.media.model.ConversationMediaItem + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun HandlePendingGallerySelectionEffect( + pendingSelectedMediaItem: ConversationMediaItem?, + sheetState: SheetState, + onGalleryMediaConfirmed: (List) -> Unit, + onShowReview: (String) -> Unit, + onSelectionHandled: () -> Unit, +) { + LaunchedEffect(pendingSelectedMediaItem) { + val mediaItem = pendingSelectedMediaItem ?: return@LaunchedEffect + + val shouldExpandSheet = sheetState.currentValue == SheetValue.Expanded || + sheetState.targetValue == SheetValue.Expanded + + if (shouldExpandSheet) { + sheetState.partialExpand() + } + + onGalleryMediaConfirmed( + listOf(mediaItem), + ) + onShowReview(mediaItem.contentUri) + onSelectionHandled() + } +} diff --git a/src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationMediaPickerOverlay.kt b/src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationMediaPickerOverlay.kt new file mode 100644 index 00000000..6414a501 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationMediaPickerOverlay.kt @@ -0,0 +1,129 @@ +package com.android.messaging.ui.conversation.v2.mediapicker + +import android.Manifest +import androidx.activity.compose.BackHandler +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.isImeVisible +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import com.android.messaging.data.media.model.ConversationMediaItem +import com.android.messaging.ui.conversation.v2.composer.model.ConversationComposerAttachmentUiState +import com.android.messaging.ui.conversation.v2.mediapicker.model.ConversationCapturedMedia +import com.android.messaging.ui.conversation.v2.mediapicker.model.ConversationMediaPickerUiState +import kotlinx.collections.immutable.ImmutableList + +@OptIn(ExperimentalLayoutApi::class) +@Composable +internal fun ConversationMediaPickerOverlay( + modifier: Modifier = Modifier, + state: ConversationMediaPickerState, + mediaPickerUiState: ConversationMediaPickerUiState, + attachments: ImmutableList, + conversationTitle: String?, + isSendActionEnabled: Boolean, + messageFieldFocusRequester: FocusRequester, + onAttachmentPreviewClick: (ConversationComposerAttachmentUiState.Resolved) -> Unit, + onAttachmentCaptionChange: (String, String) -> Unit, + onAttachmentRemove: (String) -> Unit, + onGalleryMediaConfirmed: (List) -> Unit, + onGalleryVisibilityChanged: (Boolean) -> Unit, + onCapturedMediaReady: (ConversationCapturedMedia) -> Unit, + onSendClick: () -> Unit, +) { + val context = LocalContext.current + val focusManager = LocalFocusManager.current + val isImeVisible = WindowInsets.isImeVisible + val keyboardController = LocalSoftwareKeyboardController.current + + val permissionState = rememberConversationMediaPickerPermissionState( + context = context, + ) + + val audioPermissionLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestPermission(), + ) { isGranted -> + permissionState.audioPermissionGranted = isGranted + } + + val cameraPermissionLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestPermission(), + ) { isGranted -> + permissionState.cameraPermissionGranted = isGranted + } + + val galleryPermissionLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestMultiplePermissions(), + ) { permissionResults -> + permissionState.galleryPermissionGranted = permissionResults.values.all { isGranted -> + isGranted + } + } + + HandleConversationMediaPickerGalleryVisibilityEffect( + state = state, + galleryPermissionGranted = permissionState.galleryPermissionGranted, + onGalleryVisibilityChanged = onGalleryVisibilityChanged, + ) + + HandleConversationMediaPickerVisibilityEffect( + state = state, + isImeVisible = isImeVisible, + focusManager = focusManager, + keyboardController = keyboardController, + messageFieldFocusRequester = messageFieldFocusRequester, + ) + + RefreshConversationMediaPickerPermissionsEffect( + context = context, + permissionState = permissionState, + ) + + BackHandler(enabled = state.isOpen) { + state.close() + } + + if (!state.isOpen) { + return + } + + ConversationMediaPicker( + modifier = modifier.fillMaxSize(), + uiState = mediaPickerUiState, + attachments = attachments, + conversationTitle = conversationTitle, + isSendActionEnabled = isSendActionEnabled, + state = state, + cameraPermissionGranted = permissionState.cameraPermissionGranted, + audioPermissionGranted = permissionState.audioPermissionGranted, + galleryPermissionGranted = permissionState.galleryPermissionGranted, + onClose = state::close, + onAttachmentPreviewClick = onAttachmentPreviewClick, + onAttachmentCaptionChange = onAttachmentCaptionChange, + onAttachmentRemove = onAttachmentRemove, + onGalleryMediaConfirmed = onGalleryMediaConfirmed, + onRequestAudioPermission = { + audioPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO) + }, + onRequestCameraPermission = { + cameraPermissionLauncher.launch(Manifest.permission.CAMERA) + }, + onRequestGalleryPermission = { + galleryPermissionLauncher.launch( + arrayOf( + Manifest.permission.READ_MEDIA_IMAGES, + Manifest.permission.READ_MEDIA_VIDEO, + ), + ) + }, + onCapturedMediaReady = onCapturedMediaReady, + onSendClick = onSendClick, + ) +} diff --git a/src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationMediaPickerPermission.kt b/src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationMediaPickerPermission.kt new file mode 100644 index 00000000..9bb7fadf --- /dev/null +++ b/src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationMediaPickerPermission.kt @@ -0,0 +1,133 @@ +package com.android.messaging.ui.conversation.v2.mediapicker + +import android.Manifest +import android.content.Context +import android.content.pm.PackageManager +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.focus.FocusManager +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.platform.SoftwareKeyboardController +import androidx.core.content.ContextCompat +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LifecycleEventEffect + +@Stable +internal class ConversationMediaPickerPermissionState( + context: Context, +) { + var audioPermissionGranted by mutableStateOf(value = hasAudioPermission(context = context)) + var cameraPermissionGranted by mutableStateOf(value = hasCameraPermission(context = context)) + var galleryPermissionGranted by mutableStateOf(value = hasGalleryPermissions(context = context)) + + fun refresh(context: Context) { + audioPermissionGranted = hasAudioPermission(context = context) + cameraPermissionGranted = hasCameraPermission(context = context) + galleryPermissionGranted = hasGalleryPermissions(context = context) + } +} + +@Composable +internal fun rememberConversationMediaPickerPermissionState( + context: Context, +): ConversationMediaPickerPermissionState { + return remember(context) { + ConversationMediaPickerPermissionState( + context = context, + ) + } +} + +@Composable +internal fun RefreshConversationMediaPickerPermissionsEffect( + context: Context, + permissionState: ConversationMediaPickerPermissionState, +) { + LifecycleEventEffect(event = Lifecycle.Event.ON_RESUME) { + permissionState.refresh(context = context) + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +internal fun HandleConversationMediaPickerGalleryVisibilityEffect( + state: ConversationMediaPickerState, + galleryPermissionGranted: Boolean, + onGalleryVisibilityChanged: (Boolean) -> Unit, +) { + LaunchedEffect(state.isOpen, galleryPermissionGranted) { + if (state.isOpen && galleryPermissionGranted) { + onGalleryVisibilityChanged(true) + } + } +} + +@Composable +internal fun HandleConversationMediaPickerVisibilityEffect( + state: ConversationMediaPickerState, + isImeVisible: Boolean, + focusManager: FocusManager, + keyboardController: SoftwareKeyboardController?, + messageFieldFocusRequester: FocusRequester, +) { + LaunchedEffect(state.isOpen) { + if (state.isOpen) { + state.shouldRestoreKeyboard = isImeVisible + focusManager.clearFocus(force = true) + keyboardController?.hide() + return@LaunchedEffect + } + + if (!state.shouldRestoreKeyboard) { + return@LaunchedEffect + } + + messageFieldFocusRequester.requestFocus() + keyboardController?.show() + state.shouldRestoreKeyboard = false + } +} + +private fun hasCameraPermission(context: Context): Boolean { + return isPermissionGranted( + context = context, + permission = Manifest.permission.CAMERA, + ) +} + +private fun hasAudioPermission(context: Context): Boolean { + return isPermissionGranted( + context = context, + permission = Manifest.permission.RECORD_AUDIO, + ) +} + +private fun hasGalleryPermissions(context: Context): Boolean { + val hasImagesPermission = isPermissionGranted( + context = context, + permission = Manifest.permission.READ_MEDIA_IMAGES, + ) + + val hasVideoPermission = isPermissionGranted( + context = context, + permission = Manifest.permission.READ_MEDIA_VIDEO, + ) + + return hasImagesPermission && hasVideoPermission +} + +private fun isPermissionGranted( + context: Context, + permission: String, +): Boolean { + return ContextCompat.checkSelfPermission( + context, + permission, + ) == PackageManager.PERMISSION_GRANTED +} diff --git a/src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationMediaPickerScaffold.kt b/src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationMediaPickerScaffold.kt new file mode 100644 index 00000000..c7b1f42b --- /dev/null +++ b/src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationMediaPickerScaffold.kt @@ -0,0 +1,331 @@ +package com.android.messaging.ui.conversation.v2.mediapicker + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.ContentTransform +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.BottomSheetScaffold +import androidx.compose.material3.BottomSheetScaffoldState +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.android.messaging.data.media.model.ConversationMediaItem +import com.android.messaging.ui.conversation.v2.composer.model.ConversationComposerAttachmentUiState +import com.android.messaging.ui.conversation.v2.mediapicker.camera.ConversationCameraController +import com.android.messaging.ui.conversation.v2.mediapicker.component.capture.ConversationMediaCameraPreviewSurface +import com.android.messaging.ui.conversation.v2.mediapicker.component.gallery.ConversationGallerySheet +import com.android.messaging.ui.conversation.v2.mediapicker.component.review.ConversationMediaReviewScene +import com.android.messaging.ui.conversation.v2.mediapicker.model.ConversationCapturedMedia +import com.android.messaging.ui.conversation.v2.mediapicker.model.ConversationMediaPickerUiState +import kotlinx.collections.immutable.ImmutableList + +private const val CAMERA_PREVIEW_HEIGHT_FRACTION = 2f / 3f +private val PICKER_GALLERY_SHEET_HEIGHT_REDUCTION = 16.dp + +private enum class ConversationMediaPickerOverlayMode { + Capture, + Review, +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun ConversationMediaPickerScaffold( + modifier: Modifier = Modifier, + cameraController: ConversationCameraController, + scaffoldState: BottomSheetScaffoldState, + uiState: ConversationMediaPickerUiState, + resolvedAttachments: ImmutableList, + conversationTitle: String?, + captureMode: ConversationCaptureMode, + reviewContentUri: String?, + reviewRequestSequence: Int, + isReviewVisible: Boolean, + isSendActionEnabled: Boolean, + cameraPermissionGranted: Boolean, + audioPermissionGranted: Boolean, + galleryPermissionGranted: Boolean, + onClose: () -> Unit, + onAttachmentPreviewClick: (ConversationComposerAttachmentUiState.Resolved) -> Unit, + onAttachmentCaptionChange: (String, String) -> Unit, + onAttachmentRemove: (String) -> Unit, + onGalleryMediaClick: (ConversationMediaItem) -> Unit, + onRequestAudioPermission: () -> Unit, + onRequestCameraPermission: () -> Unit, + onRequestGalleryPermission: () -> Unit, + onCapturedMediaReady: (ConversationCapturedMedia) -> Unit, + onSendClick: () -> Unit, + onShowReview: (String) -> Unit, + onClearReview: () -> Unit, + onCaptureModeChange: (ConversationCaptureMode) -> Unit, +) { + val overlayMode = when { + isReviewVisible -> ConversationMediaPickerOverlayMode.Review + else -> ConversationMediaPickerOverlayMode.Capture + } + + BoxWithConstraints( + modifier = modifier + .fillMaxSize(), + ) { + val previewHeight = maxHeight * CAMERA_PREVIEW_HEIGHT_FRACTION + val defaultSheetPeekHeight = maxHeight - previewHeight + + val sheetPeekHeight = when { + defaultSheetPeekHeight > PICKER_GALLERY_SHEET_HEIGHT_REDUCTION -> { + defaultSheetPeekHeight - PICKER_GALLERY_SHEET_HEIGHT_REDUCTION + } + + else -> defaultSheetPeekHeight + } + + AnimatedContent( + modifier = Modifier + .fillMaxSize(), + targetState = overlayMode, + transitionSpec = { + pickerOverlayTransition() + }, + label = "pickerOverlayMode", + ) { currentOverlayMode -> + when (currentOverlayMode) { + ConversationMediaPickerOverlayMode.Capture -> { + ConversationMediaPickerCaptureScene( + cameraController = cameraController, + scaffoldState = scaffoldState, + cameraPermissionGranted = cameraPermissionGranted, + onRequestCameraPermission = onRequestCameraPermission, + uiState = uiState, + galleryPermissionGranted = galleryPermissionGranted, + onGalleryMediaClick = onGalleryMediaClick, + onRequestGalleryPermission = onRequestGalleryPermission, + sheetPeekHeight = sheetPeekHeight, + audioPermissionGranted = audioPermissionGranted, + captureMode = captureMode, + onClose = onClose, + onRequestAudioPermission = onRequestAudioPermission, + onShowReview = onShowReview, + onCapturedMediaReady = onCapturedMediaReady, + onCaptureModeChange = onCaptureModeChange, + ) + } + + ConversationMediaPickerOverlayMode.Review -> { + ConversationMediaPickerReviewScene( + scaffoldState = scaffoldState, + uiState = uiState, + galleryPermissionGranted = galleryPermissionGranted, + onGalleryMediaClick = onGalleryMediaClick, + onRequestGalleryPermission = onRequestGalleryPermission, + sheetPeekHeight = sheetPeekHeight, + attachments = resolvedAttachments, + conversationTitle = conversationTitle, + initiallyReviewedContentUri = reviewContentUri, + reviewRequestSequence = reviewRequestSequence, + isSendActionEnabled = isSendActionEnabled, + onAttachmentPreviewClick = onAttachmentPreviewClick, + onCaptionChange = onAttachmentCaptionChange, + onAttachmentRemove = onAttachmentRemove, + onAddMoreClick = onClearReview, + onClearReview = onClearReview, + onCloseClick = onClose, + onSendClick = { + onSendClick() + onClose() + }, + ) + } + } + } + } +} + +@Composable +@OptIn(ExperimentalMaterial3Api::class) +private fun ConversationMediaPickerReviewScene( + scaffoldState: BottomSheetScaffoldState, + uiState: ConversationMediaPickerUiState, + galleryPermissionGranted: Boolean, + onGalleryMediaClick: (ConversationMediaItem) -> Unit, + onRequestGalleryPermission: () -> Unit, + sheetPeekHeight: Dp, + attachments: ImmutableList, + conversationTitle: String?, + initiallyReviewedContentUri: String?, + reviewRequestSequence: Int, + isSendActionEnabled: Boolean, + onAttachmentPreviewClick: (ConversationComposerAttachmentUiState.Resolved) -> Unit, + onCaptionChange: (String, String) -> Unit, + onAttachmentRemove: (String) -> Unit, + onAddMoreClick: () -> Unit, + onClearReview: () -> Unit, + onCloseClick: () -> Unit, + onSendClick: () -> Unit, +) { + BottomSheetScaffold( + modifier = Modifier + .fillMaxSize(), + scaffoldState = scaffoldState, + sheetContainerColor = MaterialTheme.colorScheme.surfaceContainerHighest.copy(alpha = 0.98f), + sheetContentColor = MaterialTheme.colorScheme.onSurface, + sheetShape = RoundedCornerShape( + topStart = 28.dp, + topEnd = 28.dp, + ), + containerColor = Color.Transparent, + sheetDragHandle = null, + sheetPeekHeight = sheetPeekHeight, + sheetContent = { + ConversationGallerySheet( + uiState = uiState, + galleryPermissionGranted = galleryPermissionGranted, + onMediaClick = onGalleryMediaClick, + onRequestGalleryPermission = onRequestGalleryPermission, + ) + }, + ) { innerPadding -> + ConversationMediaReviewScene( + modifier = Modifier.fillMaxSize(), + contentPadding = innerPadding, + attachments = attachments, + conversationTitle = conversationTitle, + initiallyReviewedContentUri = initiallyReviewedContentUri, + reviewRequestSequence = reviewRequestSequence, + isSendActionEnabled = isSendActionEnabled, + onAttachmentPreviewClick = onAttachmentPreviewClick, + onCaptionChange = onCaptionChange, + onAttachmentRemove = onAttachmentRemove, + onAddMoreClick = onAddMoreClick, + onClearReview = onClearReview, + onCloseClick = onCloseClick, + onSendClick = onSendClick, + ) + } +} + +@Composable +@OptIn(ExperimentalMaterial3Api::class) +private fun ConversationMediaPickerCaptureScene( + cameraController: ConversationCameraController, + scaffoldState: BottomSheetScaffoldState, + cameraPermissionGranted: Boolean, + onRequestCameraPermission: () -> Unit, + uiState: ConversationMediaPickerUiState, + galleryPermissionGranted: Boolean, + onGalleryMediaClick: (ConversationMediaItem) -> Unit, + onRequestGalleryPermission: () -> Unit, + sheetPeekHeight: Dp, + audioPermissionGranted: Boolean, + captureMode: ConversationCaptureMode, + onClose: () -> Unit, + onRequestAudioPermission: () -> Unit, + onShowReview: (String) -> Unit, + onCapturedMediaReady: (ConversationCapturedMedia) -> Unit, + onCaptureModeChange: (ConversationCaptureMode) -> Unit, +) { + Box( + modifier = Modifier + .fillMaxSize(), + ) { + ConversationMediaCameraPreviewRoute( + modifier = Modifier + .fillMaxSize(), + cameraController = cameraController, + cameraPermissionGranted = cameraPermissionGranted, + onRequestCameraPermission = onRequestCameraPermission, + ) + + BottomSheetScaffold( + modifier = Modifier + .fillMaxSize(), + scaffoldState = scaffoldState, + sheetContainerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + sheetContentColor = MaterialTheme.colorScheme.onSurface, + sheetShape = RoundedCornerShape( + topStart = 28.dp, + topEnd = 28.dp, + ), + containerColor = Color.Transparent, + sheetDragHandle = null, + sheetPeekHeight = sheetPeekHeight, + sheetContent = { + ConversationGallerySheet( + uiState = uiState, + galleryPermissionGranted = galleryPermissionGranted, + onMediaClick = onGalleryMediaClick, + onRequestGalleryPermission = onRequestGalleryPermission, + ) + }, + ) { innerPadding -> + ConversationMediaCaptureRoute( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues = innerPadding), + cameraController = cameraController, + audioPermissionGranted = audioPermissionGranted, + captureMode = captureMode, + onClose = onClose, + onRequestAudioPermission = onRequestAudioPermission, + onShowReview = onShowReview, + onCapturedMediaReady = onCapturedMediaReady, + onCaptureModeChange = onCaptureModeChange, + ) + } + } +} + +@Composable +private fun ConversationMediaCameraPreviewRoute( + modifier: Modifier = Modifier, + cameraController: ConversationCameraController, + cameraPermissionGranted: Boolean, + onRequestCameraPermission: () -> Unit, +) { + val surfaceRequest = cameraController.surfaceRequest.collectAsStateWithLifecycle() + + ConversationMediaCameraPreviewSurface( + modifier = modifier, + cameraPermissionGranted = cameraPermissionGranted, + surfaceRequest = surfaceRequest.value, + onRequestCameraPermission = onRequestCameraPermission, + ) +} + +private fun pickerOverlayTransition(): ContentTransform { + return ( + fadeIn( + animationSpec = tween( + durationMillis = 180, + delayMillis = 40, + ), + ) + scaleIn( + initialScale = 0.98f, + animationSpec = spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = Spring.StiffnessMediumLow, + ), + ) + ).togetherWith( + fadeOut( + animationSpec = tween(durationMillis = 100), + ) + scaleOut( + targetScale = 0.985f, + animationSpec = tween(durationMillis = 100), + ), + ) +} diff --git a/src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationMediaPickerState.kt b/src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationMediaPickerState.kt new file mode 100644 index 00000000..01b081f0 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationMediaPickerState.kt @@ -0,0 +1,133 @@ +package com.android.messaging.ui.conversation.v2.mediapicker + +import android.os.Parcelable +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import kotlinx.parcelize.Parcelize + +internal enum class ConversationCaptureMode { + Photo, + Video, +} + +@Parcelize +internal data class ConversationMediaPickerSavedState( + val captureModeName: String, + val isOpen: Boolean, + val isReviewRequested: Boolean, + val reviewContentUri: String?, + val reviewRequestSequence: Int, + val selectedMediaIds: List, + val shouldRestoreKeyboard: Boolean, +) : Parcelable + +@Stable +internal class ConversationMediaPickerState( + isOpen: Boolean, + captureMode: ConversationCaptureMode, + isReviewRequested: Boolean, + reviewContentUri: String?, + reviewRequestSequence: Int, + selectedMediaIds: Set, + shouldRestoreKeyboard: Boolean, +) { + var captureMode by mutableStateOf(captureMode) + var isOpen by mutableStateOf(isOpen) + var isReviewRequested by mutableStateOf(isReviewRequested) + var reviewContentUri by mutableStateOf(reviewContentUri) + var reviewRequestSequence by mutableIntStateOf(reviewRequestSequence) + var shouldRestoreKeyboard by mutableStateOf(shouldRestoreKeyboard) + + private var selectedMediaIds by mutableStateOf(selectedMediaIds) + + fun clearSelection() { + selectedMediaIds = emptySet() + } + + fun isSelected(mediaId: String): Boolean { + return selectedMediaIds.contains(mediaId) + } + + fun open() { + isReviewRequested = true + isOpen = true + } + + fun showReview(contentUri: String) { + isReviewRequested = true + reviewContentUri = contentUri + reviewRequestSequence += 1 + } + + fun clearReview() { + isReviewRequested = false + reviewContentUri = null + } + + fun updateCaptureMode(captureMode: ConversationCaptureMode) { + this.captureMode = captureMode + } + + fun close() { + clearSelection() + clearReview() + isOpen = false + } + + private fun toSavedState(): ConversationMediaPickerSavedState { + return ConversationMediaPickerSavedState( + captureModeName = captureMode.name, + isOpen = isOpen, + isReviewRequested = isReviewRequested, + reviewContentUri = reviewContentUri, + reviewRequestSequence = reviewRequestSequence, + selectedMediaIds = selectedMediaIds.toList(), + shouldRestoreKeyboard = shouldRestoreKeyboard, + ) + } + + companion object { + val Saver: Saver = Saver( + save = { it.toSavedState() }, + restore = { restoredState -> + ConversationMediaPickerState( + isOpen = restoredState.isOpen, + captureMode = restoredState.captureModeName.toConversationCaptureMode(), + isReviewRequested = restoredState.isReviewRequested, + reviewContentUri = restoredState.reviewContentUri, + reviewRequestSequence = restoredState.reviewRequestSequence, + selectedMediaIds = restoredState.selectedMediaIds.toSet(), + shouldRestoreKeyboard = restoredState.shouldRestoreKeyboard, + ) + }, + ) + } +} + +private fun String.toConversationCaptureMode(): ConversationCaptureMode { + return ConversationCaptureMode + .entries + .firstOrNull { it.name == this } + ?: ConversationCaptureMode.Photo +} + +@Composable +internal fun rememberConversationMediaPickerState(): ConversationMediaPickerState { + return rememberSaveable(saver = ConversationMediaPickerState.Saver) { + ConversationMediaPickerState( + isOpen = false, + captureMode = ConversationCaptureMode.Photo, + isReviewRequested = false, + reviewContentUri = null, + reviewRequestSequence = 0, + selectedMediaIds = emptySet(), + shouldRestoreKeyboard = false, + ) + } +} diff --git a/src/com/android/messaging/ui/conversation/v2/mediapicker/camera/ConversationCameraController.kt b/src/com/android/messaging/ui/conversation/v2/mediapicker/camera/ConversationCameraController.kt new file mode 100644 index 00000000..c965f41e --- /dev/null +++ b/src/com/android/messaging/ui/conversation/v2/mediapicker/camera/ConversationCameraController.kt @@ -0,0 +1,774 @@ +package com.android.messaging.ui.conversation.v2.mediapicker.camera + +import android.Manifest +import android.content.Context +import android.net.Uri +import androidx.annotation.RequiresPermission +import androidx.camera.core.Camera +import androidx.camera.core.CameraSelector +import androidx.camera.core.ImageCapture +import androidx.camera.core.ImageCaptureException +import androidx.camera.core.Preview +import androidx.camera.core.SurfaceRequest +import androidx.camera.lifecycle.ProcessCameraProvider +import androidx.camera.video.FileOutputOptions +import androidx.camera.video.PendingRecording +import androidx.camera.video.Recorder +import androidx.camera.video.Recording +import androidx.camera.video.VideoCapture +import androidx.camera.video.VideoRecordEvent +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalContext +import androidx.core.content.ContextCompat +import androidx.lifecycle.LifecycleOwner +import com.android.messaging.datamodel.MediaScratchFileProvider +import com.android.messaging.ui.conversation.v2.mediapicker.model.ConversationCapturedMedia +import com.android.messaging.util.ContentType +import com.google.common.util.concurrent.ListenableFuture +import java.io.File +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +internal interface ConversationCameraController { + val hasFlashUnit: StateFlow + val isPhotoCaptureInProgress: StateFlow + val isRecording: StateFlow + val photoFlashMode: StateFlow + val recordingDurationMillis: StateFlow + val surfaceRequest: StateFlow + + fun bindToLifecycle( + lifecycleOwner: LifecycleOwner, + onError: (Throwable) -> Unit, + ) + + fun capturePhoto( + onCaptured: (ConversationCapturedMedia) -> Unit, + onError: (Throwable) -> Unit, + ) + + fun startVideoRecording( + withAudio: Boolean, + onCaptured: (ConversationCapturedMedia) -> Unit, + onDiscarded: () -> Unit, + onError: (Throwable) -> Unit, + ) + + fun stopVideoRecording() + fun cancelVideoRecording() + + fun switchCamera(onError: (Throwable) -> Unit) + + fun cyclePhotoFlashMode(onError: (Throwable) -> Unit) + + fun unbind() +} + +private class ConversationCameraControllerImpl( + context: Context, +) : ConversationCameraController { + private val applicationContext = context.applicationContext + private val mainExecutor = ContextCompat.getMainExecutor(applicationContext) + + private val _hasFlashUnit = MutableStateFlow(false) + private val _isPhotoCaptureInProgress = MutableStateFlow(false) + private val _isRecording = MutableStateFlow(false) + private val _photoFlashMode = MutableStateFlow(ConversationPhotoFlashMode.Off) + private val _recordingDurationMillis = MutableStateFlow(0L) + private val _surfaceRequest = MutableStateFlow(null) + + override val hasFlashUnit = _hasFlashUnit.asStateFlow() + override val isPhotoCaptureInProgress = _isPhotoCaptureInProgress.asStateFlow() + override val isRecording = _isRecording.asStateFlow() + override val photoFlashMode = _photoFlashMode.asStateFlow() + override val recordingDurationMillis = _recordingDurationMillis.asStateFlow() + override val surfaceRequest = _surfaceRequest.asStateFlow() + + private var activeRecordingSession: ActiveRecordingSession? = null + private var bindGeneration = 0L + private var boundCameraSession: BoundCameraSession? = null + private var bindRequestLifecycleOwner: LifecycleOwner? = null + private var preferredLensFacing = CameraSelector.LENS_FACING_BACK + private var preferredPhotoFlashMode = ConversationPhotoFlashMode.Off + + override fun bindToLifecycle( + lifecycleOwner: LifecycleOwner, + onError: (Throwable) -> Unit, + ) { + bindRequestLifecycleOwner = lifecycleOwner + val requestedBindGeneration = ++bindGeneration + + requestCameraProvider( + lifecycleOwner = lifecycleOwner, + requestedBindGeneration = requestedBindGeneration, + onError = onError, + ) + } + + override fun capturePhoto( + onCaptured: (ConversationCapturedMedia) -> Unit, + onError: (Throwable) -> Unit, + ) { + val currentImageCapture = getReadyImageCaptureOrReportError(onError = onError) ?: return + val photoOutput = createPhotoOutputOrReportError(onError = onError) ?: return + + capturePhotoWithOutput( + imageCapture = currentImageCapture, + photoOutput = photoOutput, + onCaptured = onCaptured, + onError = onError, + ) + } + + @RequiresPermission(Manifest.permission.RECORD_AUDIO) + override fun startVideoRecording( + withAudio: Boolean, + onCaptured: (ConversationCapturedMedia) -> Unit, + onDiscarded: () -> Unit, + onError: (Throwable) -> Unit, + ) { + val currentVideoCapture = getReadyVideoCaptureOrReportError(onError = onError) ?: return + val videoOutput = createVideoOutputOrReportError(onError = onError) ?: return + val preparedRecording = prepareVideoRecording( + videoCapture = currentVideoCapture, + videoOutput = videoOutput, + withAudio = withAudio, + ) + val callbacks = VideoRecordingCallbacks( + onCaptured = onCaptured, + onDiscarded = onDiscarded, + onError = onError, + ) + + startPreparedRecording( + preparedRecording = preparedRecording, + videoOutput = videoOutput, + callbacks = callbacks, + ) + } + + override fun stopVideoRecording() { + val recording = updateRecordingDiscardOnFinalize(discardOnFinalize = false) ?: return + recording.stop() + } + + override fun cancelVideoRecording() { + val recording = updateRecordingDiscardOnFinalize(discardOnFinalize = true) ?: return + recording.stop() + } + + override fun switchCamera(onError: (Throwable) -> Unit) { + val currentBoundCameraSession = + getBoundCameraSessionOrReportError(onError = onError) ?: return + val targetLensFacing = resolveSwitchTargetLensFacing( + currentLensFacing = currentBoundCameraSession.lensFacing, + ) + + runCatching { + requireAvailableLensFacing( + processCameraProvider = currentBoundCameraSession.cameraProvider, + lensFacing = targetLensFacing, + ) + rebindForLensFacing( + boundCameraSession = currentBoundCameraSession, + lensFacing = targetLensFacing, + ) + }.onFailure(onError) + } + + override fun cyclePhotoFlashMode(onError: (Throwable) -> Unit) { + val currentBoundCameraSession = + getBoundCameraSessionOrReportError(onError = onError) ?: return + if (!_hasFlashUnit.value) { + onError(FlashUnavailableException()) + return + } + + val nextPhotoFlashMode = _photoFlashMode.value.next() + runCatching { + updatePhotoFlashMode( + imageCapture = currentBoundCameraSession.imageCapture, + photoFlashMode = nextPhotoFlashMode, + ) + }.onFailure(onError) + } + + private fun updatePhotoFlashMode( + imageCapture: ImageCapture, + photoFlashMode: ConversationPhotoFlashMode, + ) { + imageCapture.flashMode = photoFlashMode.imageCaptureFlashMode + preferredPhotoFlashMode = photoFlashMode + _photoFlashMode.value = photoFlashMode + } + + private fun resetPhotoFlashAvailabilityState() { + _hasFlashUnit.value = false + } + + private fun syncBoundImageCaptureFlashMode( + imageCapture: ImageCapture, + ) { + updatePhotoFlashMode( + imageCapture = imageCapture, + photoFlashMode = preferredPhotoFlashMode, + ) + } + + override fun unbind() { + invalidateCurrentBinding() + stopRecordingForUnbind() + clearBoundCameraReferences() + resetUiState() + } + + private fun requestCameraProvider( + lifecycleOwner: LifecycleOwner, + requestedBindGeneration: Long, + onError: (Throwable) -> Unit, + ) { + val cameraProviderFuture = ProcessCameraProvider.getInstance(applicationContext) + cameraProviderFuture.addListener( + { + handleCameraProviderReady( + cameraProviderFuture = cameraProviderFuture, + lifecycleOwner = lifecycleOwner, + requestedBindGeneration = requestedBindGeneration, + onError = onError, + ) + }, + mainExecutor, + ) + } + + private fun handleCameraProviderReady( + cameraProviderFuture: ListenableFuture, + lifecycleOwner: LifecycleOwner, + requestedBindGeneration: Long, + onError: (Throwable) -> Unit, + ) { + try { + if (!isCurrentBindGeneration(bindGeneration = requestedBindGeneration)) { + return + } + + val processCameraProvider = cameraProviderFuture.get() + if (!isCurrentBindGeneration(bindGeneration = requestedBindGeneration)) { + return + } + + rebindUseCases( + lifecycleOwner = lifecycleOwner, + processCameraProvider = processCameraProvider, + ) + } catch (throwable: Throwable) { + onError(throwable) + } + } + + private fun rebindUseCases( + lifecycleOwner: LifecycleOwner, + processCameraProvider: ProcessCameraProvider, + ) { + processCameraProvider.unbindAll() + _surfaceRequest.value = null + + val selectedLensFacing = resolveBindLensFacing( + processCameraProvider = processCameraProvider, + ) + val selectedCameraSelector = buildCameraSelector(lensFacing = selectedLensFacing) + val boundUseCases = createBoundUseCases() + val camera = processCameraProvider.bindToLifecycle( + lifecycleOwner, + selectedCameraSelector, + boundUseCases.preview, + boundUseCases.imageCapture, + boundUseCases.videoCapture, + ) + + preferredLensFacing = selectedLensFacing + val newBoundCameraSession = BoundCameraSession( + boundCamera = camera, + cameraProvider = processCameraProvider, + imageCapture = boundUseCases.imageCapture, + lifecycleOwner = lifecycleOwner, + lensFacing = selectedLensFacing, + videoCapture = boundUseCases.videoCapture, + ) + boundCameraSession = newBoundCameraSession + publishBoundCameraState(boundCameraSession = newBoundCameraSession) + } + + private fun createBoundUseCases(): BoundUseCases { + return BoundUseCases( + imageCapture = createImageCaptureUseCase(), + preview = createPreviewUseCase(), + videoCapture = createVideoCaptureUseCase(), + ) + } + + private fun createPreviewUseCase(): Preview { + return Preview.Builder().build().also { previewUseCase -> + previewUseCase.setSurfaceProvider { surfaceRequest -> + _surfaceRequest.value = surfaceRequest + } + } + } + + private fun createImageCaptureUseCase(): ImageCapture { + return ImageCapture.Builder() + .setFlashMode(preferredPhotoFlashMode.imageCaptureFlashMode) + .build() + } + + private fun createVideoCaptureUseCase(): VideoCapture { + val recorder = Recorder.Builder().build() + + return VideoCapture.withOutput(recorder) + } + + private fun publishBoundCameraState(boundCameraSession: BoundCameraSession) { + _hasFlashUnit.value = boundCameraSession.boundCamera.cameraInfo.hasFlashUnit() + syncBoundImageCaptureFlashMode( + imageCapture = boundCameraSession.imageCapture, + ) + } + + private fun getReadyImageCaptureOrReportError( + onError: (Throwable) -> Unit, + ): ImageCapture? { + if (_isPhotoCaptureInProgress.value) { + onError(PhotoCaptureAlreadyInProgressException()) + return null + } + + val currentBoundCameraSession = getBoundCameraSessionOrReportError(onError = onError) + ?: return null + + return currentBoundCameraSession.imageCapture + } + + private fun createPhotoOutputOrReportError( + onError: (Throwable) -> Unit, + ): ScratchOutput? { + val photoOutput = createScratchOutputOrNull(contentType = ContentType.IMAGE_JPEG) + if (photoOutput == null) { + onError( + ScratchFileCreationFailedException( + mediaLabel = "photo", + ), + ) + return null + } + + return photoOutput + } + + private fun capturePhotoWithOutput( + imageCapture: ImageCapture, + photoOutput: ScratchOutput, + onCaptured: (ConversationCapturedMedia) -> Unit, + onError: (Throwable) -> Unit, + ) { + val outputOptions = ImageCapture.OutputFileOptions.Builder(photoOutput.file).build() + _isPhotoCaptureInProgress.value = true + + runCatching { + imageCapture.takePicture( + outputOptions, + mainExecutor, + object : ImageCapture.OnImageSavedCallback { + override fun onError(exception: ImageCaptureException) { + handlePhotoCaptureFailure( + photoOutput = photoOutput, + exception = exception, + onError = onError, + ) + } + + override fun onImageSaved(outputFileResults: ImageCapture.OutputFileResults) { + handlePhotoCaptured( + photoOutput = photoOutput, + onCaptured = onCaptured, + ) + } + }, + ) + }.onFailure { throwable -> + _isPhotoCaptureInProgress.value = false + deleteScratchOutput(scratchOutput = photoOutput) + onError( + PhotoCaptureStartFailedException( + cause = throwable, + ), + ) + } + } + + private fun handlePhotoCaptureFailure( + photoOutput: ScratchOutput, + exception: ImageCaptureException, + onError: (Throwable) -> Unit, + ) { + _isPhotoCaptureInProgress.value = false + deleteScratchOutput(scratchOutput = photoOutput) + onError( + PhotoCaptureFailedException( + cause = exception, + ), + ) + } + + private fun handlePhotoCaptured( + photoOutput: ScratchOutput, + onCaptured: (ConversationCapturedMedia) -> Unit, + ) { + _isPhotoCaptureInProgress.value = false + onCaptured( + ConversationCapturedMedia( + contentUri = photoOutput.uri.toString(), + contentType = ContentType.IMAGE_JPEG, + ), + ) + } + + private fun getReadyVideoCaptureOrReportError( + onError: (Throwable) -> Unit, + ): VideoCapture? { + if (activeRecordingSession != null) { + onError(RecordingAlreadyInProgressException()) + return null + } + + return getBoundCameraSessionOrReportError(onError = onError)?.videoCapture + } + + private fun createVideoOutputOrReportError( + onError: (Throwable) -> Unit, + ): ScratchOutput? { + val videoOutput = createScratchOutputOrNull(contentType = ContentType.VIDEO_MP4) + if (videoOutput == null) { + onError( + ScratchFileCreationFailedException( + mediaLabel = "video", + ), + ) + return null + } + + return videoOutput + } + + @RequiresPermission(Manifest.permission.RECORD_AUDIO) + private fun prepareVideoRecording( + videoCapture: VideoCapture, + videoOutput: ScratchOutput, + withAudio: Boolean, + ): PendingRecording { + val outputOptions = FileOutputOptions.Builder(videoOutput.file).build() + var preparedRecording = videoCapture.output.prepareRecording( + applicationContext, + outputOptions, + ) + + if (withAudio) { + preparedRecording = preparedRecording.withAudioEnabled() + } + + return preparedRecording + } + + private fun startPreparedRecording( + preparedRecording: PendingRecording, + videoOutput: ScratchOutput, + callbacks: VideoRecordingCallbacks, + ) { + runCatching { + preparedRecording.start(mainExecutor) { event -> + handleVideoRecordEvent( + event = event, + callbacks = callbacks, + ) + } + }.onSuccess { recording -> + activeRecordingSession = ActiveRecordingSession( + discardOnFinalize = false, + recording = recording, + scratchOutput = videoOutput, + ) + }.onFailure { throwable -> + deleteScratchOutput(scratchOutput = videoOutput) + callbacks.onError(throwable) + } + } + + private fun handleVideoRecordEvent( + event: VideoRecordEvent, + callbacks: VideoRecordingCallbacks, + ) { + when (event) { + is VideoRecordEvent.Finalize -> { + handleVideoRecordingFinalized( + event = event, + callbacks = callbacks, + ) + } + + is VideoRecordEvent.Start -> handleVideoRecordingStarted() + + is VideoRecordEvent.Status -> handleVideoRecordingStatus(event = event) + } + } + + private fun handleVideoRecordingStarted() { + _isRecording.value = true + _recordingDurationMillis.value = 0L + } + + private fun handleVideoRecordingStatus(event: VideoRecordEvent.Status) { + _recordingDurationMillis.value = event.recordingStats.recordedDurationNanos / 1_000_000L + } + + private fun handleVideoRecordingFinalized( + event: VideoRecordEvent.Finalize, + callbacks: VideoRecordingCallbacks, + ) { + val recordingSession = clearRecordingSession() + val recordingOutput = recordingSession?.scratchOutput + + when { + recordingSession?.discardOnFinalize == true -> { + deleteScratchOutput(scratchOutput = recordingOutput) + callbacks.onDiscarded() + } + + event.error == VideoRecordEvent.Finalize.ERROR_NONE -> { + callbacks.onCaptured( + ConversationCapturedMedia( + contentUri = requireNotNull(recordingOutput).uri.toString(), + contentType = ContentType.VIDEO_MP4, + ), + ) + } + + else -> { + deleteScratchOutput(scratchOutput = recordingOutput) + callbacks.onError(createVideoRecordingFailedException(event = event)) + } + } + } + + private fun clearRecordingSession(): ActiveRecordingSession? { + _isRecording.value = false + _recordingDurationMillis.value = 0L + val recordingSession = activeRecordingSession + activeRecordingSession = null + + return recordingSession + } + + private fun invalidateCurrentBinding() { + bindGeneration += 1 + } + + private fun stopRecordingForUnbind() { + val recording = updateRecordingDiscardOnFinalize(discardOnFinalize = true) + recording?.stop() + } + + private fun clearBoundCameraReferences() { + boundCameraSession?.cameraProvider?.unbindAll() + boundCameraSession = null + + bindRequestLifecycleOwner = null + } + + private fun resetUiState() { + resetPhotoFlashAvailabilityState() + + _isPhotoCaptureInProgress.value = false + _isRecording.value = false + _recordingDurationMillis.value = 0L + _surfaceRequest.value = null + } + + private fun resolveSwitchTargetLensFacing(currentLensFacing: Int): Int { + return when (currentLensFacing) { + CameraSelector.LENS_FACING_FRONT -> CameraSelector.LENS_FACING_BACK + else -> CameraSelector.LENS_FACING_FRONT + } + } + + private fun requireAvailableLensFacing( + processCameraProvider: ProcessCameraProvider, + lensFacing: Int, + ) { + val cameraSelector = buildCameraSelector(lensFacing = lensFacing) + if (!processCameraProvider.hasCamera(cameraSelector)) { + throw CameraLensUnavailableException( + lensFacing = lensFacing, + ) + } + } + + private fun rebindForLensFacing( + boundCameraSession: BoundCameraSession, + lensFacing: Int, + ) { + preferredLensFacing = lensFacing + rebindUseCases( + lifecycleOwner = boundCameraSession.lifecycleOwner, + processCameraProvider = boundCameraSession.cameraProvider, + ) + } + + private fun resolveBindLensFacing( + processCameraProvider: ProcessCameraProvider, + ): Int { + val preferredCameraSelector = buildCameraSelector(lensFacing = preferredLensFacing) + if (processCameraProvider.hasCamera(preferredCameraSelector)) { + return preferredLensFacing + } + + val fallbackLensFacing = when (preferredLensFacing) { + CameraSelector.LENS_FACING_FRONT -> CameraSelector.LENS_FACING_BACK + else -> CameraSelector.LENS_FACING_FRONT + } + + val fallbackCameraSelector = buildCameraSelector(lensFacing = fallbackLensFacing) + if (!processCameraProvider.hasCamera(fallbackCameraSelector)) { + throw CameraLensUnavailableException( + lensFacing = fallbackLensFacing, + ) + } + + return fallbackLensFacing + } + + private fun buildCameraSelector(lensFacing: Int): CameraSelector { + return CameraSelector.Builder() + .requireLensFacing(lensFacing) + .build() + } + + private fun createScratchOutputOrNull(contentType: String): ScratchOutput? { + val scratchFileUri = MediaScratchFileProvider.buildMediaScratchSpaceUri( + resolveScratchFileExtension(contentType = contentType), + ) + + return MediaScratchFileProvider.getFileFromUri(scratchFileUri)?.let { scratchFile -> + ScratchOutput( + file = scratchFile, + uri = scratchFileUri, + ) + } + } + + private fun deleteScratchOutput(scratchOutput: ScratchOutput?) { + if (scratchOutput == null) { + return + } + + applicationContext.contentResolver.delete(scratchOutput.uri, null, null) + } + + private fun isCurrentBindGeneration(bindGeneration: Long): Boolean { + return this.bindGeneration == bindGeneration && bindRequestLifecycleOwner != null + } + + private fun getBoundCameraSessionOrReportError( + onError: (Throwable) -> Unit, + ): BoundCameraSession? { + val currentBoundCameraSession = boundCameraSession + if (currentBoundCameraSession == null) { + onError(CameraNotBoundException()) + return null + } + + return currentBoundCameraSession + } + + private fun updateRecordingDiscardOnFinalize(discardOnFinalize: Boolean): Recording? { + val currentRecordingSession = activeRecordingSession ?: return null + activeRecordingSession = currentRecordingSession.copy( + discardOnFinalize = discardOnFinalize, + ) + + return currentRecordingSession.recording + } + + private fun createVideoRecordingFailedException( + event: VideoRecordEvent.Finalize, + ): VideoRecordingFailedException { + return VideoRecordingFailedException( + cause = event.cause, + errorCode = event.error, + errorName = resolveVideoRecordingErrorName(errorCode = event.error), + ) + } + + private fun resolveVideoRecordingErrorName(errorCode: Int): String { + return when (errorCode) { + VideoRecordEvent.Finalize.ERROR_DURATION_LIMIT_REACHED -> "duration_limit_reached" + VideoRecordEvent.Finalize.ERROR_ENCODING_FAILED -> "encoding_failed" + VideoRecordEvent.Finalize.ERROR_FILE_SIZE_LIMIT_REACHED -> "file_size_limit_reached" + VideoRecordEvent.Finalize.ERROR_INSUFFICIENT_STORAGE -> "insufficient_storage" + VideoRecordEvent.Finalize.ERROR_INVALID_OUTPUT_OPTIONS -> "invalid_output_options" + VideoRecordEvent.Finalize.ERROR_NO_VALID_DATA -> "no_valid_data" + VideoRecordEvent.Finalize.ERROR_RECORDER_ERROR -> "recorder_error" + VideoRecordEvent.Finalize.ERROR_SOURCE_INACTIVE -> "source_inactive" + else -> "unknown" + } + } + + private data class BoundUseCases( + val imageCapture: ImageCapture, + val preview: Preview, + val videoCapture: VideoCapture, + ) + + private data class BoundCameraSession( + val boundCamera: Camera, + val cameraProvider: ProcessCameraProvider, + val imageCapture: ImageCapture, + val lifecycleOwner: LifecycleOwner, + val lensFacing: Int, + val videoCapture: VideoCapture, + ) + + private data class ActiveRecordingSession( + val discardOnFinalize: Boolean, + val recording: Recording, + val scratchOutput: ScratchOutput, + ) + + private data class ScratchOutput( + val file: File, + val uri: Uri, + ) + + private data class VideoRecordingCallbacks( + val onCaptured: (ConversationCapturedMedia) -> Unit, + val onDiscarded: () -> Unit, + val onError: (Throwable) -> Unit, + ) + + private fun resolveScratchFileExtension(contentType: String): String { + val mimeTypeExtension = ContentType.getExtensionFromMimeType(contentType) + + return mimeTypeExtension ?: ContentType.getExtension(contentType) + } +} + +@Composable +internal fun rememberConversationCameraController(): ConversationCameraController { + val context = LocalContext.current + + return remember(context) { + ConversationCameraControllerImpl( + context = context, + ) + } +} diff --git a/src/com/android/messaging/ui/conversation/v2/mediapicker/camera/ConversationCameraEffects.kt b/src/com/android/messaging/ui/conversation/v2/mediapicker/camera/ConversationCameraEffects.kt new file mode 100644 index 00000000..253cbd18 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/v2/mediapicker/camera/ConversationCameraEffects.kt @@ -0,0 +1,39 @@ +package com.android.messaging.ui.conversation.v2.mediapicker.camera + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.lifecycle.LifecycleOwner +import com.android.messaging.R +import com.android.messaging.util.UiUtils + +@Composable +internal fun BindConversationCameraLifecycleEffect( + cameraController: ConversationCameraController, + cameraPermissionGranted: Boolean, + isCameraPreviewVisible: Boolean, + lifecycleOwner: LifecycleOwner, +) { + DisposableEffect( + cameraController, + cameraPermissionGranted, + isCameraPreviewVisible, + lifecycleOwner, + ) { + when { + cameraPermissionGranted && isCameraPreviewVisible -> { + cameraController.bindToLifecycle( + lifecycleOwner = lifecycleOwner, + onError = { + UiUtils.showToastAtBottom(R.string.camera_error_opening) + }, + ) + } + + else -> cameraController.unbind() + } + + onDispose { + cameraController.unbind() + } + } +} diff --git a/src/com/android/messaging/ui/conversation/v2/mediapicker/camera/ConversationMediaPickerActions.kt b/src/com/android/messaging/ui/conversation/v2/mediapicker/camera/ConversationMediaPickerActions.kt new file mode 100644 index 00000000..68f82e9c --- /dev/null +++ b/src/com/android/messaging/ui/conversation/v2/mediapicker/camera/ConversationMediaPickerActions.kt @@ -0,0 +1,69 @@ +package com.android.messaging.ui.conversation.v2.mediapicker.camera + +import com.android.messaging.R +import com.android.messaging.ui.conversation.v2.mediapicker.model.ConversationCapturedMedia +import com.android.messaging.util.UiUtils + +internal fun handlePhotoCaptureRequest( + cameraController: ConversationCameraController, + onCapturedMediaReady: (ConversationCapturedMedia) -> Unit, + onShowReview: (String) -> Unit, +) { + cameraController.capturePhoto( + onCaptured = { capturedMedia -> + onCapturedMediaReady(capturedMedia) + onShowReview(capturedMedia.contentUri) + }, + onError = { + UiUtils.showToastAtBottom( + R.string.camera_error_failure_taking_picture, + ) + }, + ) +} + +internal fun handleSwitchCameraRequest(cameraController: ConversationCameraController) { + cameraController.switchCamera( + onError = { + UiUtils.showToastAtBottom( + R.string.camera_error_opening, + ) + }, + ) +} + +internal fun handleToggleFlashRequest(cameraController: ConversationCameraController) { + cameraController.cyclePhotoFlashMode( + onError = { + UiUtils.showToastAtBottom( + R.string.camera_error_opening, + ) + }, + ) +} + +internal fun handleVideoCaptureRequest( + cameraController: ConversationCameraController, + isRecording: Boolean, + onCapturedMediaReady: (ConversationCapturedMedia) -> Unit, + onShowReview: (String) -> Unit, +) { + if (isRecording) { + cameraController.stopVideoRecording() + return + } + + cameraController.startVideoRecording( + withAudio = true, + onCaptured = { capturedMedia -> + onCapturedMediaReady(capturedMedia) + onShowReview(capturedMedia.contentUri) + }, + onDiscarded = {}, + onError = { + UiUtils.showToastAtBottom( + R.string.camera_media_failure, + ) + }, + ) +} diff --git a/src/com/android/messaging/ui/conversation/v2/mediapicker/camera/ConversationPhotoFlashMode.kt b/src/com/android/messaging/ui/conversation/v2/mediapicker/camera/ConversationPhotoFlashMode.kt new file mode 100644 index 00000000..65cae93b --- /dev/null +++ b/src/com/android/messaging/ui/conversation/v2/mediapicker/camera/ConversationPhotoFlashMode.kt @@ -0,0 +1,26 @@ +package com.android.messaging.ui.conversation.v2.mediapicker.camera + +import androidx.camera.core.ImageCapture + +internal enum class ConversationPhotoFlashMode( + val imageCaptureFlashMode: Int, +) { + Off( + imageCaptureFlashMode = ImageCapture.FLASH_MODE_OFF, + ), + Auto( + imageCaptureFlashMode = ImageCapture.FLASH_MODE_AUTO, + ), + On( + imageCaptureFlashMode = ImageCapture.FLASH_MODE_ON, + ), + ; + + fun next(): ConversationPhotoFlashMode { + return when (this) { + Off -> Auto + Auto -> On + On -> Off + } + } +} diff --git a/src/com/android/messaging/ui/conversation/v2/mediapicker/camera/Exceptions.kt b/src/com/android/messaging/ui/conversation/v2/mediapicker/camera/Exceptions.kt new file mode 100644 index 00000000..35d47176 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/v2/mediapicker/camera/Exceptions.kt @@ -0,0 +1,74 @@ +package com.android.messaging.ui.conversation.v2.mediapicker.camera + +import androidx.camera.core.CameraSelector +import androidx.camera.core.ImageCaptureException + +internal sealed class ConversationCameraControllerException( + message: String, + cause: Throwable? = null, +) : IllegalStateException(message, cause) + +internal class CameraNotBoundException : + ConversationCameraControllerException( + message = "Camera is not bound", + ) + +internal class CameraLensUnavailableException( + lensFacing: Int, +) : ConversationCameraControllerException( + message = "Requested camera lens is not available: ${resolveLensFacingName( + lensFacing = lensFacing + )}", +) + +internal class PhotoCaptureFailedException( + cause: ImageCaptureException, +) : ConversationCameraControllerException( + message = "Photo capture failed", + cause = cause, +) + +internal class PhotoCaptureAlreadyInProgressException : + ConversationCameraControllerException( + message = "Photo capture is already in progress", + ) + +internal class PhotoCaptureStartFailedException( + cause: Throwable, +) : ConversationCameraControllerException( + message = "Photo capture could not be started", + cause = cause, +) + +internal class RecordingAlreadyInProgressException : + ConversationCameraControllerException( + message = "Video recording is already in progress", + ) + +internal class ScratchFileCreationFailedException( + mediaLabel: String, +) : ConversationCameraControllerException( + message = "Unable to create $mediaLabel scratch file", +) + +internal class FlashUnavailableException : + ConversationCameraControllerException( + message = "Flash is not available for the current camera", + ) + +internal class VideoRecordingFailedException( + errorCode: Int, + errorName: String, + cause: Throwable? = null, +) : ConversationCameraControllerException( + message = "Video recording failed: $errorName ($errorCode)", + cause = cause, +) + +private fun resolveLensFacingName(lensFacing: Int): String { + return when (lensFacing) { + CameraSelector.LENS_FACING_BACK -> "back" + CameraSelector.LENS_FACING_FRONT -> "front" + else -> "unknown" + } +} diff --git a/src/com/android/messaging/ui/conversation/v2/mediapicker/component/ConversationMediaPickerShared.kt b/src/com/android/messaging/ui/conversation/v2/mediapicker/component/ConversationMediaPickerShared.kt new file mode 100644 index 00000000..1f0095e6 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/v2/mediapicker/component/ConversationMediaPickerShared.kt @@ -0,0 +1,149 @@ +package com.android.messaging.ui.conversation.v2.mediapicker.component + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.CameraAlt +import androidx.compose.material3.Button +import androidx.compose.material3.FilledIconButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +private val PICKER_CONTROL_BUTTON_SIZE = 48.dp + +@Composable +internal fun PermissionFallback( + icon: @Composable () -> Unit, + message: String, + actionLabel: String, + onActionClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Surface( + modifier = modifier + .fillMaxWidth(), + shape = MaterialTheme.shapes.extraLarge, + color = MaterialTheme.colorScheme.surfaceContainerHigh, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp, vertical = 28.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Surface( + color = MaterialTheme.colorScheme.secondaryContainer, + shape = CircleShape, + ) { + Box( + modifier = Modifier + .padding(all = 14.dp), + contentAlignment = Alignment.Center, + ) { + icon() + } + } + + Text( + modifier = Modifier + .fillMaxWidth() + .padding(top = 16.dp), + text = message, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + + Button( + modifier = Modifier + .fillMaxWidth() + .padding(top = 20.dp) + .heightIn(min = 56.dp), + onClick = onActionClick, + shape = MaterialTheme.shapes.extraLarge, + ) { + Icon( + imageVector = Icons.Rounded.CameraAlt, + contentDescription = null, + ) + Spacer(modifier = Modifier.width(width = 8.dp)) + Text(text = actionLabel) + } + } + } +} + +@Composable +internal fun PickerOverlayBackgroundButton( + modifier: Modifier = Modifier, + buttonSize: Dp = PICKER_CONTROL_BUTTON_SIZE, + containerColor: Color = MaterialTheme.colorScheme.scrim.copy(alpha = 0.48f), + contentDescription: String, + iconSize: Dp = 24.dp, + imageVector: ImageVector, + onClick: () -> Unit, +) { + FilledIconButton( + modifier = modifier + .size(buttonSize), + onClick = onClick, + shape = CircleShape, + colors = IconButtonDefaults.filledIconButtonColors( + containerColor = containerColor, + contentColor = MaterialTheme.colorScheme.inverseOnSurface, + ), + ) { + Icon( + modifier = Modifier + .size(iconSize), + imageVector = imageVector, + contentDescription = contentDescription, + ) + } +} + +@Composable +internal fun PickerOverlayIconButton( + modifier: Modifier = Modifier, + contentDescription: String, + enabled: Boolean = true, + imageVector: ImageVector, + onClick: () -> Unit, +) { + FilledIconButton( + modifier = modifier + .size(PICKER_CONTROL_BUTTON_SIZE), + onClick = onClick, + enabled = enabled, + colors = IconButtonDefaults.filledIconButtonColors( + containerColor = MaterialTheme.colorScheme.scrim.copy(alpha = 0.5f), + contentColor = MaterialTheme.colorScheme.inverseOnSurface, + disabledContainerColor = MaterialTheme.colorScheme.scrim.copy(alpha = 0.25f), + disabledContentColor = MaterialTheme.colorScheme.inverseOnSurface.copy(alpha = 0.5f), + ), + shape = CircleShape, + ) { + Icon( + imageVector = imageVector, + contentDescription = contentDescription, + ) + } +} diff --git a/src/com/android/messaging/ui/conversation/v2/mediapicker/component/ConversationMediaThumbnail.kt b/src/com/android/messaging/ui/conversation/v2/mediapicker/component/ConversationMediaThumbnail.kt new file mode 100644 index 00000000..f65ba4ab --- /dev/null +++ b/src/com/android/messaging/ui/conversation/v2/mediapicker/component/ConversationMediaThumbnail.kt @@ -0,0 +1,574 @@ +package com.android.messaging.ui.conversation.v2.mediapicker.component + +import android.content.ContentResolver +import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.BitmapFactory.Options +import android.net.Uri +import android.util.Size +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.FilterQuality +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.IntSize +import androidx.core.graphics.scale +import androidx.core.net.toUri +import coil3.compose.AsyncImage +import coil3.request.ImageRequest +import com.android.messaging.util.ContentType +import com.android.messaging.util.MediaMetadataRetrieverWrapper +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +private const val FIRST_PASS_DIVISOR = 12 +private const val FIRST_PASS_MAXIMUM_SIZE = 24 +private const val FIRST_PASS_MINIMUM_SIZE = 12 +private const val SECOND_PASS_DIVISOR = 3 +private const val SECOND_PASS_MAXIMUM_SIZE = 48 +private const val SECOND_PASS_MINIMUM_SIZE = 24 +private const val THUMBNAIL_FADE_IN_DURATION_MILLIS = 90 + +@Composable +internal fun ConversationMediaThumbnail( + modifier: Modifier = Modifier, + contentUri: String, + contentType: String, + size: IntSize, + contentScale: ContentScale = ContentScale.Crop, + crossfadeEnabled: Boolean = true, + backgroundColor: Color = Color.Unspecified, + useBitmapLoader: Boolean = false, + softenBitmap: Boolean = false, +) { + val context = LocalContext.current + + val contentUriAsUri = rememberContentUri(contentUri = contentUri) + val normalizedSize = remember(size) { + size.sanitized() + } + + val resolvedBackgroundColor = resolveBackgroundColor(backgroundColor = backgroundColor) + + val shouldUseCoilImageLoader = shouldUseCoilImageLoader( + contentType = contentType, + useBitmapLoader = useBitmapLoader, + ) + + when { + shouldUseCoilImageLoader -> { + CoilThumbnail( + modifier = modifier, + context = context, + contentUri = contentUriAsUri, + size = normalizedSize, + contentScale = contentScale, + crossfadeEnabled = crossfadeEnabled, + ) + } + + else -> { + BitmapThumbnail( + modifier = modifier, + contentUri = contentUriAsUri, + contentType = contentType, + size = normalizedSize, + contentScale = contentScale, + crossfadeEnabled = crossfadeEnabled, + backgroundColor = resolvedBackgroundColor, + softenBitmap = softenBitmap, + useBitmapLoader = useBitmapLoader, + ) + } + } +} + +@Composable +internal fun rememberConversationMediaThumbnailBitmap( + contentUri: Uri, + contentType: String, + size: IntSize, + softenBitmap: Boolean = false, +): Bitmap? { + val context = LocalContext.current + + val bitmap by produceState( + initialValue = null, + contentUri, + contentType, + size, + softenBitmap, + ) { + value = loadConversationMediaThumbnailBitmap( + contentResolver = context.contentResolver, + contentUri = contentUri, + contentType = contentType, + size = size, + softenBitmap = softenBitmap, + ) + } + + return bitmap +} + +@Composable +private fun BitmapThumbnail( + modifier: Modifier, + contentUri: Uri, + contentType: String, + size: IntSize, + contentScale: ContentScale, + crossfadeEnabled: Boolean, + backgroundColor: Color, + softenBitmap: Boolean, + useBitmapLoader: Boolean, +) { + val bitmap = rememberConversationMediaThumbnailBitmap( + contentUri = contentUri, + contentType = contentType, + size = size, + softenBitmap = softenBitmap, + ) + val bitmapAlpha = rememberThumbnailAlpha( + crossfadeEnabled = crossfadeEnabled, + isLoaded = bitmap != null, + animationLabel = "conversationMediaThumbnailBitmapAlpha", + ) + val filterQuality = resolveBitmapFilterQuality(useBitmapLoader = useBitmapLoader) + + Box(modifier = modifier) { + ThumbnailPlaceholder( + modifier = Modifier.fillMaxSize(), + backgroundColor = backgroundColor, + ) + + if (bitmap != null) { + Image( + bitmap = bitmap.asImageBitmap(), + contentDescription = null, + contentScale = contentScale, + filterQuality = filterQuality, + modifier = Modifier + .fillMaxSize() + .alpha(alpha = bitmapAlpha), + ) + } + } +} + +@Composable +private fun CoilThumbnail( + modifier: Modifier, + context: Context, + contentUri: Uri, + size: IntSize, + contentScale: ContentScale, + crossfadeEnabled: Boolean, +) { + val imageRequest = remember( + context, + contentUri, + size, + ) { + ImageRequest.Builder(context) + .data(contentUri) + .size(width = size.width, height = size.height) + .build() + } + + val isImageLoaded = remember(contentUri, size, crossfadeEnabled) { + mutableStateOf(value = !crossfadeEnabled) + } + + val visibilityAlpha = rememberThumbnailAlpha( + crossfadeEnabled = crossfadeEnabled, + isLoaded = isImageLoaded.value, + animationLabel = "conversationMediaThumbnailImageAlpha", + ) + + AsyncImage( + model = imageRequest, + contentDescription = null, + contentScale = contentScale, + filterQuality = FilterQuality.Low, + modifier = modifier.alpha(alpha = visibilityAlpha), + onError = { + isImageLoaded.value = true + }, + onSuccess = { + isImageLoaded.value = true + }, + ) +} + +@Composable +private fun ThumbnailPlaceholder( + modifier: Modifier, + backgroundColor: Color, +) { + Surface( + modifier = modifier, + color = backgroundColor, + ) { + Box( + modifier = Modifier + .fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + } + } +} + +internal suspend fun loadConversationMediaThumbnailBitmap( + contentResolver: ContentResolver, + contentUri: Uri, + contentType: String, + size: IntSize, + softenBitmap: Boolean, +): Bitmap? { + return withContext(context = Dispatchers.IO) { + val rawBitmap = loadPlatformThumbnail( + contentResolver = contentResolver, + contentUri = contentUri, + size = size, + ) ?: loadFallbackBitmap( + contentResolver = contentResolver, + contentUri = contentUri, + contentType = contentType, + size = size, + ) + + maybeSoftenBitmap( + bitmap = rawBitmap, + outputSize = size, + softenBitmap = softenBitmap, + ) + } +} + +private fun createSoftenedBitmap( + sourceBitmap: Bitmap, + outputSize: IntSize, +): Bitmap { + val sanitizedOutputSize = outputSize.sanitized() + val targetWidth = sanitizedOutputSize.width + val targetHeight = sanitizedOutputSize.height + val centerCroppedBitmap = createCenterCroppedBitmap( + sourceBitmap = sourceBitmap, + targetSize = sanitizedOutputSize, + ) + + // Multi-pass downscaling keeps softened placeholders smooth without introducing blur kernels + val firstPassWidth = (targetWidth / FIRST_PASS_DIVISOR).coerceIn( + minimumValue = FIRST_PASS_MINIMUM_SIZE, + maximumValue = FIRST_PASS_MAXIMUM_SIZE, + ) + val firstPassHeight = (targetHeight / FIRST_PASS_DIVISOR).coerceIn( + minimumValue = FIRST_PASS_MINIMUM_SIZE, + maximumValue = FIRST_PASS_MAXIMUM_SIZE, + ) + val secondPassWidth = (targetWidth / SECOND_PASS_DIVISOR).coerceIn( + minimumValue = SECOND_PASS_MINIMUM_SIZE, + maximumValue = SECOND_PASS_MAXIMUM_SIZE, + ) + val secondPassHeight = (targetHeight / SECOND_PASS_DIVISOR).coerceIn( + minimumValue = SECOND_PASS_MINIMUM_SIZE, + maximumValue = SECOND_PASS_MAXIMUM_SIZE, + ) + + val firstPassBitmap = centerCroppedBitmap.scale(firstPassWidth, firstPassHeight) + val secondPassBitmap = firstPassBitmap.scale(secondPassWidth, secondPassHeight) + + return secondPassBitmap.scale(targetWidth, targetHeight) +} + +private fun createCenterCroppedBitmap( + sourceBitmap: Bitmap, + targetSize: IntSize, +): Bitmap { + val sanitizedTargetSize = targetSize.sanitized() + val targetAspectRatio = + sanitizedTargetSize.width.toFloat() / sanitizedTargetSize.height.toFloat() + val sourceAspectRatio = sourceBitmap.width.toFloat() / sourceBitmap.height.toFloat() + + val cropWidth: Int + val cropHeight: Int + + when { + sourceAspectRatio > targetAspectRatio -> { + cropHeight = sourceBitmap.height + cropWidth = (cropHeight * targetAspectRatio).toInt() + } + + else -> { + cropWidth = sourceBitmap.width + cropHeight = (cropWidth / targetAspectRatio).toInt() + } + } + + val left = (sourceBitmap.width - cropWidth) / 2 + val top = (sourceBitmap.height - cropHeight) / 2 + + return Bitmap.createBitmap( + sourceBitmap, + left, + top, + cropWidth.coerceAtLeast(minimumValue = 1), + cropHeight.coerceAtLeast(minimumValue = 1), + ) +} + +@Composable +private fun rememberContentUri( + contentUri: String, +): Uri { + return remember(contentUri) { + contentUri.toUri() + } +} + +@Composable +private fun rememberThumbnailAlpha( + crossfadeEnabled: Boolean, + isLoaded: Boolean, + animationLabel: String, +): Float { + val targetAlpha = when { + !crossfadeEnabled || isLoaded -> 1f + else -> 0f + } + + val animatedAlpha by animateFloatAsState( + targetValue = targetAlpha, + animationSpec = tween(durationMillis = THUMBNAIL_FADE_IN_DURATION_MILLIS), + label = animationLabel, + ) + + return animatedAlpha +} + +@Composable +private fun resolveBackgroundColor( + backgroundColor: Color, +): Color { + return when { + backgroundColor != Color.Unspecified -> backgroundColor + else -> MaterialTheme.colorScheme.surfaceContainerHigh + } +} + +private fun shouldUseCoilImageLoader( + contentType: String, + useBitmapLoader: Boolean, +): Boolean { + return ContentType.isImageType(contentType) && !useBitmapLoader +} + +private fun resolveBitmapFilterQuality(useBitmapLoader: Boolean): FilterQuality { + return when { + useBitmapLoader -> FilterQuality.Medium + else -> FilterQuality.Low + } +} + +private fun loadPlatformThumbnail( + contentResolver: ContentResolver, + contentUri: Uri, + size: IntSize, +): Bitmap? { + return runCatching { + contentResolver.loadThumbnail( + contentUri, + Size(size.width, size.height), + null, + ) + }.getOrNull() +} + +private fun loadFallbackBitmap( + contentResolver: ContentResolver, + contentUri: Uri, + contentType: String, + size: IntSize, +): Bitmap? { + return when { + ContentType.isImageType(contentType) -> { + loadImageBitmapFallback( + contentResolver = contentResolver, + contentUri = contentUri, + size = size, + ) + } + + ContentType.isVideoType(contentType) -> { + loadVideoFrameFallback( + contentUri = contentUri, + size = size, + ) + } + + else -> null + } +} + +private fun loadImageBitmapFallback( + contentResolver: ContentResolver, + contentUri: Uri, + size: IntSize, +): Bitmap? { + return runCatching { + val decodeBoundsOptions = Options().apply { + inJustDecodeBounds = true + } + + contentResolver + .openInputStream(contentUri) + ?.use { inputStream -> + BitmapFactory.decodeStream(inputStream, null, decodeBoundsOptions) + } + + val decodeBitmapOptions = Options().apply { + inSampleSize = calculateBitmapSampleSize( + sourceWidth = decodeBoundsOptions.outWidth, + sourceHeight = decodeBoundsOptions.outHeight, + targetSize = size, + ) + } + + contentResolver + .openInputStream(contentUri) + ?.use { inputStream -> + BitmapFactory.decodeStream(inputStream, null, decodeBitmapOptions) + } + }.getOrNull() +} + +private fun loadVideoFrameFallback( + contentUri: Uri, + size: IntSize, +): Bitmap? { + val retriever = MediaMetadataRetrieverWrapper() + + return try { + runCatching { + retriever.setDataSource(contentUri) + retriever.frameAtTime?.let { bitmap -> + scaleBitmapDownIfNeeded( + bitmap = bitmap, + targetSize = size, + ) + } + }.getOrNull() + } finally { + retriever.release() + } +} + +private fun maybeSoftenBitmap( + bitmap: Bitmap?, + outputSize: IntSize, + softenBitmap: Boolean, +): Bitmap? { + return when { + bitmap == null -> null + !softenBitmap -> bitmap + + else -> { + createSoftenedBitmap( + sourceBitmap = bitmap, + outputSize = outputSize, + ) + } + } +} + +private fun calculateBitmapSampleSize( + sourceWidth: Int, + sourceHeight: Int, + targetSize: IntSize, +): Int { + if (sourceWidth <= 0 || sourceHeight <= 0) { + return 1 + } + + var sampleSize = 1 + val targetWidth = targetSize.width.coerceAtLeast(minimumValue = 1) + val targetHeight = targetSize.height.coerceAtLeast(minimumValue = 1) + + while ( + canDoubleBitmapSampleSize( + sourceWidth = sourceWidth, + sourceHeight = sourceHeight, + sampleSize = sampleSize, + targetWidth = targetWidth, + targetHeight = targetHeight, + ) + ) { + sampleSize *= 2 + } + + return sampleSize +} + +private fun canDoubleBitmapSampleSize( + sourceWidth: Int, + sourceHeight: Int, + sampleSize: Int, + targetWidth: Int, + targetHeight: Int, +): Boolean { + val doubledSampleSize = sampleSize * 2 + val doubledDecodedWidth = sourceWidth / doubledSampleSize + val doubledDecodedHeight = sourceHeight / doubledSampleSize + + return doubledDecodedWidth >= targetWidth && + doubledDecodedHeight >= targetHeight +} + +private fun scaleBitmapDownIfNeeded( + bitmap: Bitmap, + targetSize: IntSize, +): Bitmap { + val targetWidth = targetSize.width.coerceAtLeast(minimumValue = 1) + val targetHeight = targetSize.height.coerceAtLeast(minimumValue = 1) + + if (bitmap.width <= targetWidth && bitmap.height <= targetHeight) { + return bitmap + } + + val widthScale = targetWidth.toFloat() / bitmap.width.toFloat() + val heightScale = targetHeight.toFloat() / bitmap.height.toFloat() + val scale = minOf(widthScale, heightScale) + + return bitmap.scale( + width = (bitmap.width * scale).toInt().coerceAtLeast(minimumValue = 1), + height = (bitmap.height * scale).toInt().coerceAtLeast(minimumValue = 1), + ) +} + +private fun IntSize.sanitized(): IntSize { + if (width >= 1 && height >= 1) { + return this + } + + return IntSize( + width = width.coerceAtLeast(minimumValue = 1), + height = height.coerceAtLeast(minimumValue = 1), + ) +} diff --git a/src/com/android/messaging/ui/conversation/v2/mediapicker/component/capture/ConversationMediaCaptureControls.kt b/src/com/android/messaging/ui/conversation/v2/mediapicker/component/capture/ConversationMediaCaptureControls.kt new file mode 100644 index 00000000..61204da0 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/v2/mediapicker/component/capture/ConversationMediaCaptureControls.kt @@ -0,0 +1,237 @@ +package com.android.messaging.ui.conversation.v2.mediapicker.component.capture + +import androidx.compose.foundation.clickable +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.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Cameraswitch +import androidx.compose.material.icons.rounded.Close +import androidx.compose.material.icons.rounded.FlashAuto +import androidx.compose.material.icons.rounded.FlashOff +import androidx.compose.material.icons.rounded.FlashOn +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.android.messaging.R +import com.android.messaging.ui.conversation.v2.mediapicker.ConversationCaptureMode +import com.android.messaging.ui.conversation.v2.mediapicker.camera.ConversationPhotoFlashMode +import com.android.messaging.ui.conversation.v2.mediapicker.component.PickerOverlayIconButton +import java.util.Locale + +@Composable +internal fun ConversationMediaCaptureTopBar( + modifier: Modifier = Modifier, + captureMode: ConversationCaptureMode, + hasFlashUnit: Boolean, + isPhotoCaptureInProgress: Boolean, + isRecording: Boolean, + photoFlashMode: ConversationPhotoFlashMode, + onCloseClick: () -> Unit, + onFlashClick: () -> Unit, +) { + Row( + modifier = modifier + .fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + PickerOverlayIconButton( + contentDescription = stringResource( + id = R.string.conversation_media_picker_close_content_description, + ), + imageVector = Icons.Rounded.Close, + onClick = onCloseClick, + ) + if (hasFlashUnit && captureMode == ConversationCaptureMode.Photo) { + PickerOverlayIconButton( + contentDescription = stringResource( + id = R.string.conversation_media_picker_cycle_flash_mode_content_description, + ), + enabled = !isPhotoCaptureInProgress && !isRecording, + imageVector = when (photoFlashMode) { + ConversationPhotoFlashMode.Auto -> Icons.Rounded.FlashAuto + ConversationPhotoFlashMode.Off -> Icons.Rounded.FlashOff + ConversationPhotoFlashMode.On -> Icons.Rounded.FlashOn + }, + onClick = onFlashClick, + ) + } + } +} + +@Composable +internal fun ConversationMediaCaptureControls( + modifier: Modifier = Modifier, + captureMode: ConversationCaptureMode, + isPhotoCaptureInProgress: Boolean, + isRecording: Boolean, + recordingDurationMillis: Long, + onCaptureClick: () -> Unit, + onPhotoModeClick: () -> Unit, + onSwitchCameraClick: () -> Unit, + onVideoModeClick: () -> Unit, +) { + Column( + modifier = modifier, + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Row( + modifier = Modifier + .fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + Spacer(modifier = Modifier.width(48.dp)) + + Column( + modifier = Modifier + .weight(weight = 1f), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + if (isRecording) { + ConversationMediaRecordingTimerPill( + durationMillis = recordingDurationMillis, + ) + } + + ConversationMediaCaptureShutterButton( + captureMode = captureMode, + isPhotoCaptureInProgress = isPhotoCaptureInProgress, + isRecording = isRecording, + onClick = onCaptureClick, + ) + + ConversationMediaCaptureModeToggle( + captureMode = captureMode, + enabled = !isPhotoCaptureInProgress && !isRecording, + onPhotoModeClick = onPhotoModeClick, + onVideoModeClick = onVideoModeClick, + ) + } + PickerOverlayIconButton( + modifier = Modifier + .align(Alignment.CenterVertically), + contentDescription = stringResource( + id = R.string.camera_switch_camera_facing, + ), + enabled = !isPhotoCaptureInProgress && !isRecording, + imageVector = Icons.Rounded.Cameraswitch, + onClick = onSwitchCameraClick, + ) + } + } +} + +@Composable +private fun ConversationMediaCaptureModeToggle( + captureMode: ConversationCaptureMode, + enabled: Boolean, + onPhotoModeClick: () -> Unit, + onVideoModeClick: () -> Unit, +) { + Surface( + shape = CircleShape, + color = MaterialTheme.colorScheme.scrim.copy(alpha = 0.4f), + ) { + Row( + modifier = Modifier + .padding(4.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + ConversationMediaCaptureModeChip( + isSelected = captureMode == ConversationCaptureMode.Photo, + label = stringResource(id = R.string.conversation_media_picker_photo_mode), + enabled = enabled, + onClick = onPhotoModeClick, + ) + + ConversationMediaCaptureModeChip( + isSelected = captureMode == ConversationCaptureMode.Video, + label = stringResource(id = R.string.conversation_media_picker_video_mode), + enabled = enabled, + onClick = onVideoModeClick, + ) + } + } +} + +@Composable +private fun ConversationMediaCaptureModeChip( + isSelected: Boolean, + label: String, + enabled: Boolean, + onClick: () -> Unit, +) { + Surface( + modifier = Modifier + .height(36.dp) + .clip(CircleShape) + .clickable( + enabled = enabled, + onClick = onClick, + ), + shape = CircleShape, + color = when { + isSelected -> MaterialTheme.colorScheme.secondaryContainer + else -> MaterialTheme.colorScheme.scrim.copy(alpha = 0f) + }, + ) { + Box( + modifier = Modifier + .padding(horizontal = 14.dp, vertical = 8.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = label, + color = when { + isSelected -> MaterialTheme.colorScheme.onSecondaryContainer + else -> MaterialTheme.colorScheme.inverseOnSurface.copy(alpha = 0.9f) + }, + style = MaterialTheme.typography.labelLarge, + ) + } + } +} + +@Composable +private fun ConversationMediaRecordingTimerPill( + durationMillis: Long, +) { + Surface( + shape = CircleShape, + color = MaterialTheme.colorScheme.errorContainer, + ) { + Text( + modifier = Modifier + .padding(horizontal = 14.dp, vertical = 8.dp), + text = formatRecordingDuration(durationMillis = durationMillis), + color = MaterialTheme.colorScheme.onErrorContainer, + style = MaterialTheme.typography.labelLarge, + ) + } +} + +private fun formatRecordingDuration(durationMillis: Long): String { + val totalSeconds = durationMillis / 1_000L + val minutes = totalSeconds / 60L + val seconds = totalSeconds % 60L + + return String.format(Locale.getDefault(), "%02d:%02d", minutes, seconds) +} diff --git a/src/com/android/messaging/ui/conversation/v2/mediapicker/component/capture/ConversationMediaCaptureShutterButton.kt b/src/com/android/messaging/ui/conversation/v2/mediapicker/component/capture/ConversationMediaCaptureShutterButton.kt new file mode 100644 index 00000000..04c96af5 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/v2/mediapicker/component/capture/ConversationMediaCaptureShutterButton.kt @@ -0,0 +1,471 @@ +package com.android.messaging.ui.conversation.v2.mediapicker.component.capture + +import androidx.compose.animation.animateColor +import androidx.compose.animation.core.Transition +import androidx.compose.animation.core.animateDp +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.animation.core.updateTransition +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ColorScheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.android.messaging.ui.conversation.v2.mediapicker.ConversationCaptureMode +import com.android.messaging.ui.conversation.v2.mediapicker.component.capture.ConversationMediaCaptureShutterPhase.Photo +import com.android.messaging.ui.conversation.v2.mediapicker.component.capture.ConversationMediaCaptureShutterPhase.VideoIdle +import com.android.messaging.ui.conversation.v2.mediapicker.component.capture.ConversationMediaCaptureShutterPhase.VideoRecording +import com.android.messaging.ui.core.AppTheme + +private val PICKER_SHUTTER_BORDER_WIDTH = 3.dp +private val PICKER_SHUTTER_OUTER_SIZE = 78.dp +private val PICKER_SHUTTER_PHOTO_INNER_SIZE = 62.dp +private val PICKER_SHUTTER_FULL_INNER_SIZE = PICKER_SHUTTER_OUTER_SIZE - + (PICKER_SHUTTER_BORDER_WIDTH * 2) +private const val PICKER_SHUTTER_STATE_TRANSITION_SPRING_DAMPING_RATIO = 0.7f +private const val PICKER_SHUTTER_STATE_TRANSITION_SPRING_STIFFNESS = 500f +private val PICKER_SHUTTER_COLOR_ANIMATION_SPEC = tween(durationMillis = 180) +private val PICKER_SHUTTER_FLOAT_SPRING_ANIMATION_SPEC = spring( + dampingRatio = PICKER_SHUTTER_STATE_TRANSITION_SPRING_DAMPING_RATIO, + stiffness = PICKER_SHUTTER_STATE_TRANSITION_SPRING_STIFFNESS, +) + +private enum class ConversationMediaCaptureShutterPhase { + Photo, + VideoIdle, + VideoRecording, +} + +@Composable +internal fun ConversationMediaCaptureShutterButton( + captureMode: ConversationCaptureMode, + isPhotoCaptureInProgress: Boolean, + isRecording: Boolean, + onClick: () -> Unit, +) { + val colorScheme = MaterialTheme.colorScheme + val isEnabled = captureMode != ConversationCaptureMode.Photo || !isPhotoCaptureInProgress + val shutterPhase = resolveConversationMediaCaptureShutterPhase( + captureMode = captureMode, + isRecording = isRecording, + ) + ConversationMediaCaptureShutterButtonAnimatedContent( + colorScheme = colorScheme, + isEnabled = isEnabled, + onClick = onClick, + shutterPhase = shutterPhase, + ) +} + +@Composable +private fun ConversationMediaCaptureShutterButtonAnimatedContent( + colorScheme: ColorScheme, + isEnabled: Boolean, + onClick: () -> Unit, + shutterPhase: ConversationMediaCaptureShutterPhase, +) { + val transition = updateTransition( + targetState = shutterPhase, + label = "picker_shutter_phase", + ) + val outerContainerColor by transition.animateOuterContainerColor(colorScheme) + val innerShutterColor by transition.animateInnerShutterColor(colorScheme) + val innerShutterSize by transition.animateInnerShutterSize() + val outerScale by transition.animateOuterScale() + val videoCenterDotAlpha by transition.animateVideoCenterDotAlpha() + val videoCenterDotScale by transition.animateVideoCenterDotScale() + val recordingStopAlpha by transition.animateRecordingStopAlpha() + val recordingStopScale by transition.animateRecordingStopScale() + + ConversationMediaCaptureShutterButtonShell( + borderColor = colorScheme.inverseOnSurface, + isEnabled = isEnabled, + onClick = onClick, + outerContainerColor = outerContainerColor, + outerScale = outerScale, + ) { + ConversationMediaCaptureShutterInnerDisc( + innerShutterColor = innerShutterColor, + innerShutterSize = innerShutterSize, + ) { + if (shutterPhase != Photo) { + ConversationMediaCaptureVideoOverlay( + recordingStopAlpha = recordingStopAlpha, + recordingStopBackgroundColor = colorScheme.error.copy(alpha = 0.3f), + recordingStopScale = recordingStopScale, + videoCenterDotAlpha = videoCenterDotAlpha, + videoCenterDotColor = colorScheme.inverseOnSurface, + videoCenterDotScale = videoCenterDotScale, + ) + } + } + } +} + +@Composable +private fun Transition.animateInnerShutterColor( + colorScheme: ColorScheme, +): State { + return animateColor( + transitionSpec = { + PICKER_SHUTTER_COLOR_ANIMATION_SPEC + }, + label = "picker_shutter_inner_color", + targetValueByState = { phase -> + phase.resolveInnerShutterColor( + colorScheme = colorScheme, + ) + }, + ) +} + +@Composable +private fun Transition.animateInnerShutterSize(): State { + return animateDp( + transitionSpec = { + spring( + dampingRatio = PICKER_SHUTTER_STATE_TRANSITION_SPRING_DAMPING_RATIO, + stiffness = PICKER_SHUTTER_STATE_TRANSITION_SPRING_STIFFNESS, + ) + }, + label = "picker_shutter_inner_size", + targetValueByState = { phase -> + phase.resolveInnerShutterSize() + }, + ) +} + +@Composable +private fun Transition.animateOuterContainerColor( + colorScheme: ColorScheme, +): State { + return animateColor( + transitionSpec = { + PICKER_SHUTTER_COLOR_ANIMATION_SPEC + }, + label = "picker_shutter_outer_color", + targetValueByState = { phase -> + phase.resolveOuterContainerColor( + colorScheme = colorScheme, + ) + }, + ) +} + +@Composable +private fun Transition.animateOuterScale(): State { + return animateFloat( + transitionSpec = { + PICKER_SHUTTER_FLOAT_SPRING_ANIMATION_SPEC + }, + label = "picker_shutter_outer_scale", + targetValueByState = { phase -> + phase.resolveOuterScale() + }, + ) +} + +@Composable +private fun Transition.animateRecordingStopAlpha(): + State { + return animateFloat( + transitionSpec = { + tween(durationMillis = 130) + }, + label = "picker_shutter_recording_stop_alpha", + targetValueByState = { phase -> + phase.resolveRecordingStopAlpha() + }, + ) +} + +@Composable +private fun Transition.animateRecordingStopScale(): + State { + return animateFloat( + transitionSpec = { + PICKER_SHUTTER_FLOAT_SPRING_ANIMATION_SPEC + }, + label = "picker_shutter_recording_stop_scale", + targetValueByState = { phase -> + phase.resolveRecordingStopScale() + }, + ) +} + +@Composable +private fun Transition.animateVideoCenterDotAlpha(): + State { + return animateFloat( + transitionSpec = { + tween(durationMillis = 110) + }, + label = "picker_shutter_video_center_dot_alpha", + targetValueByState = { phase -> + phase.resolveVideoCenterDotAlpha() + }, + ) +} + +@Composable +private fun Transition.animateVideoCenterDotScale(): + State { + return animateFloat( + transitionSpec = { + PICKER_SHUTTER_FLOAT_SPRING_ANIMATION_SPEC + }, + label = "picker_shutter_video_center_dot_scale", + targetValueByState = { phase -> + phase.resolveVideoCenterDotScale() + }, + ) +} + +@Composable +private fun ConversationMediaCaptureShutterButtonShell( + borderColor: Color, + isEnabled: Boolean, + onClick: () -> Unit, + outerContainerColor: Color, + outerScale: Float, + content: @Composable () -> Unit, +) { + Surface( + modifier = Modifier + .size(PICKER_SHUTTER_OUTER_SIZE) + .graphicsLayer { + alpha = if (isEnabled) 1f else 0.7f + scaleX = outerScale + scaleY = outerScale + }, + enabled = isEnabled, + onClick = onClick, + shape = CircleShape, + color = outerContainerColor, + border = BorderStroke( + width = PICKER_SHUTTER_BORDER_WIDTH, + color = borderColor, + ), + ) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + content() + } + } +} + +@Composable +private fun ConversationMediaCaptureShutterInnerDisc( + innerShutterColor: Color, + innerShutterSize: Dp, + content: @Composable BoxScope.() -> Unit, +) { + Surface( + modifier = Modifier.size(innerShutterSize), + shape = CircleShape, + color = innerShutterColor, + ) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + content = content, + ) + } +} + +@Composable +private fun ConversationMediaCaptureVideoOverlay( + recordingStopAlpha: Float, + recordingStopBackgroundColor: Color, + recordingStopScale: Float, + videoCenterDotAlpha: Float, + videoCenterDotColor: Color, + videoCenterDotScale: Float, +) { + ConversationMediaCaptureRecordingStopGlyph( + alpha = recordingStopAlpha, + backgroundColor = recordingStopBackgroundColor, + scale = recordingStopScale, + ) + + ConversationMediaCaptureVideoIdleDotGlyph( + alpha = videoCenterDotAlpha, + color = videoCenterDotColor, + scale = videoCenterDotScale, + ) +} + +@Composable +private fun ConversationMediaCaptureRecordingStopGlyph( + alpha: Float, + backgroundColor: Color, + scale: Float, +) { + Box( + modifier = Modifier + .size(28.dp) + .graphicsLayer { + this.alpha = alpha + scaleX = scale + scaleY = scale + } + .background( + color = backgroundColor, + shape = RoundedCornerShape(size = 10.dp), + ), + ) +} + +@Composable +private fun ConversationMediaCaptureVideoIdleDotGlyph( + alpha: Float, + color: Color, + scale: Float, +) { + Surface( + modifier = Modifier + .size(16.dp) + .graphicsLayer { + this.alpha = alpha + scaleX = scale + scaleY = scale + }, + shape = CircleShape, + color = color, + ) {} +} + +private fun resolveConversationMediaCaptureShutterPhase( + captureMode: ConversationCaptureMode, + isRecording: Boolean, +): ConversationMediaCaptureShutterPhase { + return when { + isRecording -> VideoRecording + captureMode == ConversationCaptureMode.Video -> VideoIdle + else -> Photo + } +} + +private fun ConversationMediaCaptureShutterPhase.resolveInnerShutterColor( + colorScheme: ColorScheme, +): Color { + return when (this) { + Photo -> colorScheme.inverseOnSurface + VideoIdle -> colorScheme.scrim.copy(alpha = 0.5f) + VideoRecording -> colorScheme.errorContainer + } +} + +private fun ConversationMediaCaptureShutterPhase.resolveInnerShutterSize(): Dp { + return when (this) { + Photo -> PICKER_SHUTTER_PHOTO_INNER_SIZE + + VideoIdle, + VideoRecording, + -> PICKER_SHUTTER_FULL_INNER_SIZE + } +} + +private fun ConversationMediaCaptureShutterPhase.resolveOuterContainerColor( + colorScheme: ColorScheme, +): Color { + return when (this) { + Photo -> colorScheme.scrim.copy(alpha = 0.2f) + + VideoIdle, + VideoRecording, + -> Color.Transparent + } +} + +private fun ConversationMediaCaptureShutterPhase.resolveOuterScale(): Float { + return when (this) { + Photo, + VideoIdle, + -> 1f + + VideoRecording -> 0.97f + } +} + +private fun ConversationMediaCaptureShutterPhase.resolveRecordingStopAlpha(): Float { + return when (this) { + Photo, + VideoIdle, + -> 0f + + VideoRecording -> 1f + } +} + +private fun ConversationMediaCaptureShutterPhase.resolveRecordingStopScale(): Float { + return when (this) { + Photo, + VideoIdle, + -> 0.8f + + VideoRecording -> 1f + } +} + +private fun ConversationMediaCaptureShutterPhase.resolveVideoCenterDotAlpha(): Float { + return when (this) { + Photo, + VideoRecording, + -> 0f + + VideoIdle -> 1f + } +} + +private fun ConversationMediaCaptureShutterPhase.resolveVideoCenterDotScale(): Float { + return when (this) { + Photo, + VideoRecording, + -> 0.72f + + VideoIdle -> 1f + } +} + +@Composable +private fun ConversationMediaCaptureShutterButtonPreviewContainer( + captureMode: ConversationCaptureMode, + isPhotoCaptureInProgress: Boolean = false, + isRecording: Boolean = false, +) { + AppTheme { + Surface(color = Color.Black.copy(alpha = 0.5f)) { + Box( + modifier = Modifier.padding(16.dp), + contentAlignment = Alignment.Center, + ) { + ConversationMediaCaptureShutterButton( + captureMode = captureMode, + isPhotoCaptureInProgress = isPhotoCaptureInProgress, + isRecording = isRecording, + onClick = {}, + ) + } + } + } +} diff --git a/src/com/android/messaging/ui/conversation/v2/mediapicker/component/capture/ConversationMediaPickerCapture.kt b/src/com/android/messaging/ui/conversation/v2/mediapicker/component/capture/ConversationMediaPickerCapture.kt new file mode 100644 index 00000000..0b7b13f8 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/v2/mediapicker/component/capture/ConversationMediaPickerCapture.kt @@ -0,0 +1,172 @@ +package com.android.messaging.ui.conversation.v2.mediapicker.component.capture + +import androidx.camera.compose.CameraXViewfinder +import androidx.camera.core.SurfaceRequest +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.CameraAlt +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.android.messaging.R +import com.android.messaging.ui.conversation.v2.mediapicker.ConversationCaptureMode +import com.android.messaging.ui.conversation.v2.mediapicker.camera.ConversationPhotoFlashMode +import com.android.messaging.ui.conversation.v2.mediapicker.component.PermissionFallback + +@Composable +internal fun ConversationMediaCameraPreviewSurface( + modifier: Modifier = Modifier, + cameraPermissionGranted: Boolean, + surfaceRequest: SurfaceRequest?, + onRequestCameraPermission: () -> Unit, +) { + Box( + modifier = modifier + .background(color = MaterialTheme.colorScheme.scrim), + ) { + when { + !cameraPermissionGranted -> { + ConversationMediaCameraPermissionFallback( + onRequestCameraPermission = onRequestCameraPermission, + ) + } + + surfaceRequest == null -> { + ConversationMediaCameraLoadingState() + } + + else -> { + ConversationMediaCameraViewfinder( + surfaceRequest = surfaceRequest, + ) + } + } + } +} + +@Composable +private fun ConversationMediaCameraPermissionFallback( + onRequestCameraPermission: () -> Unit, +) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 24.dp), + contentAlignment = Alignment.Center, + ) { + PermissionFallback( + icon = { + Icon( + imageVector = Icons.Rounded.CameraAlt, + contentDescription = null, + ) + }, + message = stringResource( + id = R.string.conversation_media_picker_camera_permission_message, + ), + actionLabel = stringResource( + id = R.string.conversation_media_picker_allow_camera, + ), + onActionClick = onRequestCameraPermission, + ) + } +} + +@Composable +private fun ConversationMediaCameraLoadingState() { + Box( + modifier = Modifier + .fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } +} + +@Composable +private fun ConversationMediaCameraViewfinder( + surfaceRequest: SurfaceRequest, +) { + CameraXViewfinder( + modifier = Modifier + .fillMaxSize(), + surfaceRequest = surfaceRequest, + ) +} + +@Composable +internal fun ConversationMediaCaptureContent( + modifier: Modifier = Modifier, + audioPermissionGranted: Boolean, + captureMode: ConversationCaptureMode, + hasFlashUnit: Boolean, + isPhotoCaptureInProgress: Boolean, + isRecording: Boolean, + photoFlashMode: ConversationPhotoFlashMode, + onCloseClick: () -> Unit, + onRequestAudioPermission: () -> Unit, + onPhotoCaptureClick: () -> Unit, + onPhotoModeClick: () -> Unit, + onSwitchCameraClick: () -> Unit, + onToggleFlashClick: () -> Unit, + onVideoCaptureClick: () -> Unit, + onVideoModeClick: () -> Unit, + recordingDurationMillis: Long, +) { + Box( + modifier = modifier, + ) { + ConversationMediaCaptureTopBar( + modifier = Modifier + .align(Alignment.TopStart) + .fillMaxWidth() + .statusBarsPadding() + .padding(horizontal = 16.dp, vertical = 12.dp), + captureMode = captureMode, + hasFlashUnit = hasFlashUnit, + isPhotoCaptureInProgress = isPhotoCaptureInProgress, + isRecording = isRecording, + photoFlashMode = photoFlashMode, + onCloseClick = onCloseClick, + onFlashClick = onToggleFlashClick, + ) + + ConversationMediaCaptureControls( + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(horizontal = 20.dp, vertical = 24.dp), + captureMode = captureMode, + isPhotoCaptureInProgress = isPhotoCaptureInProgress, + isRecording = isRecording, + recordingDurationMillis = recordingDurationMillis, + onCaptureClick = { + when (captureMode) { + ConversationCaptureMode.Video -> { + when { + !isRecording && !audioPermissionGranted -> { + onRequestAudioPermission() + } + + else -> onVideoCaptureClick() + } + } + + else -> onPhotoCaptureClick() + } + }, + onPhotoModeClick = onPhotoModeClick, + onSwitchCameraClick = onSwitchCameraClick, + onVideoModeClick = onVideoModeClick, + ) + } +} diff --git a/src/com/android/messaging/ui/conversation/v2/mediapicker/component/gallery/ConversationMediaPickerGallery.kt b/src/com/android/messaging/ui/conversation/v2/mediapicker/component/gallery/ConversationMediaPickerGallery.kt new file mode 100644 index 00000000..44cd4429 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/v2/mediapicker/component/gallery/ConversationMediaPickerGallery.kt @@ -0,0 +1,235 @@ +package com.android.messaging.ui.conversation.v2.mediapicker.component.gallery + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.GridItemSpan +import androidx.compose.foundation.lazy.grid.LazyGridScope +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.PhotoLibrary +import androidx.compose.material.icons.rounded.PlayArrow +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import com.android.messaging.R +import com.android.messaging.data.media.model.ConversationMediaItem +import com.android.messaging.data.media.model.ConversationMediaType +import com.android.messaging.ui.conversation.v2.mediapicker.component.ConversationMediaThumbnail +import com.android.messaging.ui.conversation.v2.mediapicker.component.PermissionFallback +import com.android.messaging.ui.conversation.v2.mediapicker.model.ConversationMediaPickerUiState + +private val GALLERY_GRID_SPACING = 8.dp +private val GALLERY_ITEM_CORNER_RADIUS = 20.dp +private const val GALLERY_ITEM_SIZE_PX = 384 + +@Composable +internal fun ConversationGallerySheet( + uiState: ConversationMediaPickerUiState, + galleryPermissionGranted: Boolean, + onMediaClick: (ConversationMediaItem) -> Unit, + onRequestGalleryPermission: () -> Unit, +) { + LazyVerticalGrid( + modifier = Modifier.navigationBarsPadding(), + columns = GridCells.Fixed(3), + contentPadding = PaddingValues( + start = 16.dp, + top = 12.dp, + end = 16.dp, + bottom = 20.dp, + ), + horizontalArrangement = Arrangement.spacedBy(GALLERY_GRID_SPACING), + verticalArrangement = Arrangement.spacedBy(GALLERY_GRID_SPACING), + ) { + item( + span = { + GridItemSpan(maxLineSpan) + }, + ) { + GallerySheetDragHandle() + } + + when { + !galleryPermissionGranted -> { + galleryPermissionItem( + onRequestGalleryPermission = onRequestGalleryPermission, + ) + } + + uiState.isLoadingGallery -> { + galleryLoadingItem() + } + + else -> { + galleryItems( + items = uiState.galleryItems, + onMediaClick = onMediaClick, + ) + } + } + } +} + +private fun LazyGridScope.galleryPermissionItem( + onRequestGalleryPermission: () -> Unit, +) { + item( + span = { + GridItemSpan(maxLineSpan) + }, + ) { + PermissionFallback( + icon = { + Icon( + imageVector = Icons.Rounded.PhotoLibrary, + contentDescription = null, + ) + }, + message = stringResource( + id = R.string.conversation_media_picker_gallery_permission_message, + ), + actionLabel = stringResource( + id = R.string.conversation_media_picker_allow_gallery, + ), + onActionClick = onRequestGalleryPermission, + ) + } +} + +private fun LazyGridScope.galleryLoadingItem() { + item( + span = { + GridItemSpan(maxLineSpan) + }, + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 24.dp), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } + } +} + +private fun LazyGridScope.galleryItems( + items: List, + onMediaClick: (ConversationMediaItem) -> Unit, +) { + items( + items = items, + key = { item -> item.mediaId }, + ) { item -> + GalleryGridItem( + item = item, + onClick = { + onMediaClick(item) + }, + ) + } +} + +@Composable +private fun GallerySheetDragHandle( + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier + .fillMaxWidth() + .padding(bottom = 4.dp), + contentAlignment = Alignment.Center, + ) { + Box( + modifier = Modifier + .size( + width = 32.dp, + height = 4.dp, + ) + .clip(CircleShape) + .background( + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f), + ), + ) + } +} + +@Composable +private fun GalleryGridItem( + item: ConversationMediaItem, + onClick: () -> Unit, +) { + val thumbnailSize = IntSize( + width = GALLERY_ITEM_SIZE_PX, + height = GALLERY_ITEM_SIZE_PX, + ) + + Surface( + modifier = Modifier + .fillMaxWidth() + .aspectRatio(1f) + .clip(RoundedCornerShape(GALLERY_ITEM_CORNER_RADIUS)) + .clickable(onClick = onClick), + shape = RoundedCornerShape(GALLERY_ITEM_CORNER_RADIUS), + color = MaterialTheme.colorScheme.surfaceContainerHighest, + ) { + Box { + ConversationMediaThumbnail( + modifier = Modifier.fillMaxSize(), + contentUri = item.contentUri, + contentType = item.contentType, + size = thumbnailSize, + ) + + if (item.mediaType == ConversationMediaType.Video) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Rounded.PlayArrow, + contentDescription = null, + tint = MaterialTheme.colorScheme.onPrimary, + ) + } + } + } + } +} + + +private fun previewMediaItem( + id: String, + type: ConversationMediaType, +): ConversationMediaItem { + return ConversationMediaItem( + mediaId = id, + contentUri = "content://media/external/images/media/$id", + contentType = if (type == ConversationMediaType.Image) "image/jpeg" else "video/mp4", + mediaType = type, + width = 1080, + height = 1920, + durationMillis = if (type == ConversationMediaType.Video) 30000L else null, + ) +} diff --git a/src/com/android/messaging/ui/conversation/v2/mediapicker/component/review/ConversationMediaPickerReview.kt b/src/com/android/messaging/ui/conversation/v2/mediapicker/component/review/ConversationMediaPickerReview.kt new file mode 100644 index 00000000..0a316f01 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/v2/mediapicker/component/review/ConversationMediaPickerReview.kt @@ -0,0 +1,355 @@ +package com.android.messaging.ui.conversation.v2.mediapicker.component.review + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.ime +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.PageSize +import androidx.compose.foundation.pager.PagerState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.AddAPhoto +import androidx.compose.material.icons.rounded.Close +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.SideEffect +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.Color +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import com.android.messaging.R +import com.android.messaging.ui.conversation.v2.composer.model.ConversationComposerAttachmentUiState +import com.android.messaging.ui.conversation.v2.composer.ui.ConversationSendActionButton +import com.android.messaging.ui.conversation.v2.mediapicker.component.PickerOverlayIconButton +import kotlinx.collections.immutable.ImmutableList + +private const val PICKER_REVIEW_PAGE_ASPECT_RATIO = 0.8f +private const val PICKER_REVIEW_PAGE_MAX_HEIGHT_FRACTION = 0.95f +private const val PICKER_REVIEW_PAGE_WIDTH_FRACTION = 0.8f + +@Composable +internal fun ConversationMediaReviewScene( + modifier: Modifier = Modifier, + contentPadding: PaddingValues = PaddingValues(), + attachments: ImmutableList, + conversationTitle: String?, + initiallyReviewedContentUri: String?, + reviewRequestSequence: Int, + isSendActionEnabled: Boolean, + onAttachmentPreviewClick: (ConversationComposerAttachmentUiState.Resolved) -> Unit, + onCaptionChange: (String, String) -> Unit, + onAttachmentRemove: (String) -> Unit, + onAddMoreClick: () -> Unit, + onClearReview: () -> Unit, + onCloseClick: () -> Unit, + onSendClick: () -> Unit, +) { + if (attachments.isEmpty()) { + return + } + + val reviewPagerState = rememberConversationMediaReviewPagerState( + attachments = attachments, + initiallyReviewedContentUri = initiallyReviewedContentUri, + reviewRequestSequence = reviewRequestSequence, + ) + + val imeBottomPadding = WindowInsets.ime.asPaddingValues().calculateBottomPadding() + + val reviewBottomPadding = maxOf( + contentPadding.calculateBottomPadding(), + imeBottomPadding, + ) + 12.dp + + Box( + modifier = modifier, + ) { + ConversationMediaReviewBackground( + modifier = Modifier + .fillMaxSize(), + pagerState = reviewPagerState.pagerState, + attachments = attachments, + ) + + Column( + modifier = Modifier + .fillMaxSize() + .padding( + top = 12.dp, + bottom = reviewBottomPadding, + ), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + ConversationMediaReviewTopBar( + conversationTitle = conversationTitle, + onAddMoreClick = onAddMoreClick, + onCloseClick = onCloseClick, + ) + + ConversationMediaReviewPager( + modifier = Modifier + .weight(weight = 1f) + .fillMaxWidth(), + attachmentContentUris = reviewPagerState.attachmentContentUris, + attachments = attachments, + pagerState = reviewPagerState.pagerState, + visibleDeleteChipPage = reviewPagerState.visibleDeleteChipPage, + onAttachmentPreviewClick = onAttachmentPreviewClick, + onAttachmentRemove = onAttachmentRemove, + onClearReview = onClearReview, + ) + + ConversationMediaReviewBottomBar( + attachment = reviewPagerState.currentAttachment, + isSendActionEnabled = isSendActionEnabled, + onCaptionChange = onCaptionChange, + onSendClick = onSendClick, + ) + } + } +} + +@Composable +private fun ConversationMediaReviewTopBar( + conversationTitle: String?, + onAddMoreClick: () -> Unit, + onCloseClick: () -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .statusBarsPadding(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + PickerOverlayIconButton( + contentDescription = stringResource( + id = R.string.conversation_media_picker_close_content_description, + ), + imageVector = Icons.Rounded.Close, + onClick = onCloseClick, + ) + Text( + modifier = Modifier + .weight(weight = 1f), + text = conversationTitle.orEmpty(), + color = MaterialTheme.colorScheme.inverseOnSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.titleMedium, + ) + PickerOverlayIconButton( + contentDescription = stringResource( + id = R.string.conversation_media_picker_add_more_content_description, + ), + imageVector = Icons.Rounded.AddAPhoto, + onClick = onAddMoreClick, + ) + } +} + +@Composable +private fun ConversationMediaReviewPager( + modifier: Modifier = Modifier, + attachmentContentUris: ImmutableList, + attachments: ImmutableList, + pagerState: PagerState, + visibleDeleteChipPage: Int?, + onAttachmentPreviewClick: (ConversationComposerAttachmentUiState.Resolved) -> Unit, + onAttachmentRemove: (String) -> Unit, + onClearReview: () -> Unit, +) { + BoxWithConstraints( + modifier = modifier, + ) { + val maxPageWidth = maxWidth * PICKER_REVIEW_PAGE_WIDTH_FRACTION + val maxPageHeight = maxHeight * PICKER_REVIEW_PAGE_MAX_HEIGHT_FRACTION + val pageWidthFromHeight = maxPageHeight * PICKER_REVIEW_PAGE_ASPECT_RATIO + + val pageWidth = when { + maxPageWidth <= pageWidthFromHeight -> maxPageWidth + else -> pageWidthFromHeight + } + + val pageHeight = pageWidth / PICKER_REVIEW_PAGE_ASPECT_RATIO + val pageHorizontalInset = (maxWidth - pageWidth) / 2 + val density = LocalDensity.current + val currentPreviewSize = remember(pageWidth, pageHeight, density) { + with(density) { + IntSize( + width = pageWidth.roundToPx().coerceAtLeast(minimumValue = 1), + height = pageHeight.roundToPx().coerceAtLeast(minimumValue = 1), + ) + } + } + + val previewSize = rememberLargestReviewPreviewSize( + currentPreviewSize = currentPreviewSize, + ) + + HorizontalPager( + modifier = Modifier + .fillMaxSize() + .padding(top = 16.dp), + state = pagerState, + contentPadding = PaddingValues(horizontal = pageHorizontalInset), + pageSize = PageSize.Fixed(pageWidth), + pageSpacing = 12.dp, + key = { page -> + attachmentContentUris.getOrElse(index = page) { + "stale-review-page-$page" + } + }, + ) { page -> + val attachment = attachments.getOrNull(index = page) + + when { + attachment != null -> { + ConversationMediaReviewPageCard( + attachment = attachment, + attachments = attachments, + page = page, + pageHeight = pageHeight, + pageWidth = pageWidth, + pagerState = pagerState, + previewSize = previewSize, + shouldShowDeleteChip = page == visibleDeleteChipPage, + onAttachmentPreviewClick = onAttachmentPreviewClick, + onAttachmentRemove = onAttachmentRemove, + onClearReview = onClearReview, + ) + } + + else -> { + Box( + modifier = Modifier.fillMaxSize(), + ) + } + } + } + } +} + +@Composable +private fun rememberLargestReviewPreviewSize( + currentPreviewSize: IntSize, +): IntSize { + var largestPreviewSize by remember { + mutableStateOf(value = currentPreviewSize) + } + + SideEffect { + val updatedPreviewSize = IntSize( + width = maxOf( + largestPreviewSize.width, + currentPreviewSize.width, + ), + height = maxOf( + largestPreviewSize.height, + currentPreviewSize.height, + ), + ) + + if (updatedPreviewSize != largestPreviewSize) { + largestPreviewSize = updatedPreviewSize + } + } + + return largestPreviewSize +} + +@Composable +private fun ReviewCaptionTextField( + modifier: Modifier = Modifier, + captionText: String, + onCaptionChange: (String) -> Unit, +) { + val containerColor = MaterialTheme.colorScheme.surfaceContainerLowest.copy(alpha = 0.95f) + + TextField( + modifier = modifier + .fillMaxWidth(), + value = captionText, + onValueChange = onCaptionChange, + shape = RoundedCornerShape(28.dp), + colors = TextFieldDefaults.colors( + focusedContainerColor = containerColor, + unfocusedContainerColor = containerColor, + disabledContainerColor = containerColor, + focusedTextColor = MaterialTheme.colorScheme.onSurface, + unfocusedTextColor = MaterialTheme.colorScheme.onSurface, + disabledTextColor = MaterialTheme.colorScheme.onSurfaceVariant, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + disabledIndicatorColor = Color.Transparent, + cursorColor = MaterialTheme.colorScheme.primary, + focusedPlaceholderColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.8f), + unfocusedPlaceholderColor = MaterialTheme.colorScheme.onSurfaceVariant.copy( + alpha = 0.8f + ), + disabledPlaceholderColor = MaterialTheme.colorScheme.onSurfaceVariant.copy( + alpha = 0.5f + ), + ), + placeholder = { + Text( + text = stringResource(R.string.conversation_media_picker_caption_hint), + ) + }, + singleLine = true, + ) +} + +@Composable +private fun ConversationMediaReviewBottomBar( + attachment: ConversationComposerAttachmentUiState.Resolved, + isSendActionEnabled: Boolean, + onCaptionChange: (String, String) -> Unit, + onSendClick: () -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + ReviewCaptionTextField( + modifier = Modifier.weight(weight = 1f), + captionText = attachment.captionText, + onCaptionChange = { captionText -> + onCaptionChange( + attachment.contentUri, + captionText, + ) + }, + ) + + ConversationSendActionButton( + enabled = isSendActionEnabled, + onClick = onSendClick, + ) + } +} diff --git a/src/com/android/messaging/ui/conversation/v2/mediapicker/component/review/ConversationMediaReviewBackground.kt b/src/com/android/messaging/ui/conversation/v2/mediapicker/component/review/ConversationMediaReviewBackground.kt new file mode 100644 index 00000000..9ac4fb9a --- /dev/null +++ b/src/com/android/messaging/ui/conversation/v2/mediapicker/component/review/ConversationMediaReviewBackground.kt @@ -0,0 +1,213 @@ +package com.android.messaging.ui.conversation.v2.mediapicker.component.review + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.pager.PagerState +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.FilterQuality +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.IntSize +import androidx.core.net.toUri +import com.android.messaging.ui.conversation.v2.composer.model.ConversationComposerAttachmentUiState +import com.android.messaging.ui.conversation.v2.mediapicker.component.loadConversationMediaThumbnailBitmap +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList + +private const val PICKER_REVIEW_BACKGROUND_BITMAP_SIZE_PX = 40 + +@Composable +internal fun ConversationMediaReviewBackground( + modifier: Modifier = Modifier, + pagerState: PagerState, + attachments: ImmutableList, +) { + val backgroundState = rememberConversationMediaReviewBackgroundState( + pagerState = pagerState, + attachments = attachments, + ) + + ConversationMediaReviewBackgroundContent( + modifier = modifier, + state = backgroundState, + ) +} + +@Composable +private fun ConversationMediaReviewBackgroundContent( + modifier: Modifier = Modifier, + state: ConversationMediaReviewBackgroundState, +) { + Box( + modifier = modifier + .fillMaxSize() + .background( + color = state.fallbackBackgroundColor, + ), + ) { + if (state.settledBackgroundImageBitmap != null) { + Image( + bitmap = state.settledBackgroundImageBitmap, + contentDescription = null, + contentScale = ContentScale.Crop, + filterQuality = FilterQuality.Low, + modifier = Modifier.fillMaxSize(), + ) + + Box( + modifier = Modifier + .fillMaxSize() + .background( + color = MaterialTheme.colorScheme.scrim.copy(alpha = 0.5f), + ), + ) + } + } +} + +@Composable +private fun rememberConversationMediaReviewBackgroundState( + pagerState: PagerState, + attachments: ImmutableList, +): ConversationMediaReviewBackgroundState { + val backgroundSelection = remember( + attachments, + pagerState.settledPage, + ) { + getConversationMediaReviewBackgroundSelection( + attachments = attachments, + settledPage = pagerState.settledPage, + ) + } + + val backgroundBitmapCache = rememberConversationMediaReviewBitmapCache( + attachments = attachments, + attachmentsToPrefetch = backgroundSelection.attachmentsToPrefetch, + ) + + val fallbackBackgroundColor = MaterialTheme + .colorScheme + .surfaceContainerHighest + .copy(alpha = 0.9f) + + val settledBackgroundBitmap = backgroundSelection + .attachmentsToPrefetch + .firstOrNull() + ?.let { attachment -> + backgroundBitmapCache[attachment.contentUri] + } + + val settledBackgroundImageBitmap = settledBackgroundBitmap?.asImageBitmap() + + return ConversationMediaReviewBackgroundState( + settledBackgroundImageBitmap = settledBackgroundImageBitmap, + fallbackBackgroundColor = fallbackBackgroundColor, + ) +} + +@Composable +private fun rememberConversationMediaReviewBitmapCache( + attachments: ImmutableList, + attachmentsToPrefetch: ImmutableList, +): ConversationMediaReviewBitmapCache { + val context = LocalContext.current + + val backgroundBitmapCache = remember { + ConversationMediaReviewBitmapCache() + } + + LaunchedEffect(attachments) { + backgroundBitmapCache.removeInactive( + activeContentUris = attachments + .asSequence() + .map { it.contentUri } + .toSet(), + ) + } + + LaunchedEffect(attachmentsToPrefetch) { + attachmentsToPrefetch + .asSequence() + .filter { backgroundBitmapCache[it.contentUri] == null } + .forEach { attachment -> + loadConversationMediaThumbnailBitmap( + contentResolver = context.contentResolver, + contentUri = attachment.contentUri.toUri(), + contentType = attachment.contentType, + size = IntSize( + width = PICKER_REVIEW_BACKGROUND_BITMAP_SIZE_PX, + height = PICKER_REVIEW_BACKGROUND_BITMAP_SIZE_PX, + ), + softenBitmap = true, + )?.let { bitmap -> + backgroundBitmapCache.put( + contentUri = attachment.contentUri, + bitmap = bitmap, + ) + } + } + } + + return backgroundBitmapCache +} + +private fun getConversationMediaReviewBackgroundSelection( + attachments: ImmutableList, + settledPage: Int, +): ConversationMediaReviewBackgroundSelection { + if (attachments.isEmpty()) { + return ConversationMediaReviewBackgroundSelection( + attachmentsToPrefetch = persistentListOf(), + ) + } + + val settledIndex = settledPage.coerceIn( + minimumValue = 0, + maximumValue = attachments.lastIndex, + ) + + val settledAttachment = attachments[settledIndex] + + val previousAttachment = attachments + .getOrNull(index = settledIndex - 1) + ?.takeIf { it.contentUri != settledAttachment.contentUri } + + val nextAttachment = attachments + .getOrNull(index = settledIndex + 1) + ?.takeIf { attachment -> + attachment.contentUri != settledAttachment.contentUri && + attachment.contentUri != previousAttachment?.contentUri + } + + val attachmentsToPrefetch = listOfNotNull( + settledAttachment, + previousAttachment, + nextAttachment, + ).toImmutableList() + + return ConversationMediaReviewBackgroundSelection( + attachmentsToPrefetch = attachmentsToPrefetch, + ) +} + +@Immutable +private data class ConversationMediaReviewBackgroundSelection( + val attachmentsToPrefetch: ImmutableList, +) + +@Immutable +private data class ConversationMediaReviewBackgroundState( + val settledBackgroundImageBitmap: ImageBitmap?, + val fallbackBackgroundColor: Color, +) diff --git a/src/com/android/messaging/ui/conversation/v2/mediapicker/component/review/ConversationMediaReviewBitmapCache.kt b/src/com/android/messaging/ui/conversation/v2/mediapicker/component/review/ConversationMediaReviewBitmapCache.kt new file mode 100644 index 00000000..ebfb9071 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/v2/mediapicker/component/review/ConversationMediaReviewBitmapCache.kt @@ -0,0 +1,29 @@ +package com.android.messaging.ui.conversation.v2.mediapicker.component.review + +import android.graphics.Bitmap +import androidx.compose.runtime.Stable +import androidx.compose.runtime.mutableStateMapOf + +@Stable +internal class ConversationMediaReviewBitmapCache { + private val cachedBackgroundBitmapsByContentUri = mutableStateMapOf() + + operator fun get(contentUri: String): Bitmap? { + return cachedBackgroundBitmapsByContentUri[contentUri] + } + + fun put(contentUri: String, bitmap: Bitmap) { + cachedBackgroundBitmapsByContentUri[contentUri] = bitmap + } + + fun removeInactive(activeContentUris: Set) { + cachedBackgroundBitmapsByContentUri + .keys + .asSequence() + .filterNot { it in activeContentUris } + .toSet() + .let { inactiveContentUris -> + cachedBackgroundBitmapsByContentUri -= inactiveContentUris + } + } +} diff --git a/src/com/android/messaging/ui/conversation/v2/mediapicker/component/review/ConversationMediaReviewPageCard.kt b/src/com/android/messaging/ui/conversation/v2/mediapicker/component/review/ConversationMediaReviewPageCard.kt new file mode 100644 index 00000000..13fdd3ba --- /dev/null +++ b/src/com/android/messaging/ui/conversation/v2/mediapicker/component/review/ConversationMediaReviewPageCard.kt @@ -0,0 +1,331 @@ +package com.android.messaging.ui.conversation.v2.mediapicker.component.review + +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.pager.PagerState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Close +import androidx.compose.material.icons.rounded.PlayArrow +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.surfaceColorAtElevation +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.LaunchedEffect +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.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.lerp +import androidx.compose.ui.zIndex +import com.android.messaging.R +import com.android.messaging.ui.conversation.v2.composer.model.ConversationComposerAttachmentUiState +import com.android.messaging.ui.conversation.v2.mediapicker.component.ConversationMediaThumbnail +import com.android.messaging.ui.conversation.v2.mediapicker.component.PickerOverlayBackgroundButton +import com.android.messaging.util.ContentType +import kotlin.math.absoluteValue +import kotlinx.collections.immutable.ImmutableList +import kotlinx.coroutines.delay + +private const val PICKER_REVIEW_PAGE_REMOVE_ANIMATION_DURATION_MILLIS = 160 + +@Composable +internal fun ConversationMediaReviewPageCard( + attachment: ConversationComposerAttachmentUiState.Resolved, + attachments: ImmutableList, + page: Int, + pageHeight: Dp, + pageWidth: Dp, + pagerState: PagerState, + previewSize: IntSize, + shouldShowDeleteChip: Boolean, + onAttachmentPreviewClick: (ConversationComposerAttachmentUiState.Resolved) -> Unit, + onAttachmentRemove: (String) -> Unit, + onClearReview: () -> Unit, +) { + val pageCardState = rememberConversationMediaReviewPageCardState( + attachment = attachment, + attachments = attachments, + shouldShowDeleteChip = shouldShowDeleteChip, + onAttachmentRemove = onAttachmentRemove, + onClearReview = onClearReview, + ) + + ConversationMediaReviewPageCardContent( + attachment = attachment, + page = page, + pageHeight = pageHeight, + pageWidth = pageWidth, + pagerState = pagerState, + previewSize = previewSize, + contentState = pageCardState.contentState, + onAttachmentPreviewClick = onAttachmentPreviewClick, + onAttachmentRemoveClick = { pageCardState.markPendingRemoval() }, + ) +} + +@Composable +private fun rememberConversationMediaReviewPageCardState( + attachment: ConversationComposerAttachmentUiState.Resolved, + attachments: ImmutableList, + shouldShowDeleteChip: Boolean, + onAttachmentRemove: (String) -> Unit, + onClearReview: () -> Unit, +): ConversationMediaReviewPageCardState { + var isPendingRemoval by remember(attachment.contentUri) { + mutableStateOf(false) + } + + val deleteChipVisibilityProgress by animateFloatAsState( + targetValue = when { + shouldShowDeleteChip && !isPendingRemoval -> 1f + else -> 0f + }, + animationSpec = tween(durationMillis = 120), + label = "reviewDeleteChipVisibility", + ) + + val removalVisibilityProgress by animateFloatAsState( + targetValue = when { + isPendingRemoval -> 0f + else -> 1f + }, + animationSpec = tween(durationMillis = PICKER_REVIEW_PAGE_REMOVE_ANIMATION_DURATION_MILLIS), + label = "reviewPageRemovalVisibility", + ) + + val shouldClearReviewAfterRemoval = attachments.size == 1 + + LaunchedEffect(isPendingRemoval) { + if (!isPendingRemoval) { + return@LaunchedEffect + } + + delay(timeMillis = PICKER_REVIEW_PAGE_REMOVE_ANIMATION_DURATION_MILLIS.toLong()) + onAttachmentRemove(attachment.contentUri) + + if (shouldClearReviewAfterRemoval) { + onClearReview() + } + } + + return ConversationMediaReviewPageCardState( + contentState = ConversationMediaReviewPageCardContentState( + isPreviewEnabled = !isPendingRemoval, + isDeleteChipVisible = deleteChipVisibilityProgress > 0f, + deleteChipVisibilityProgress = deleteChipVisibilityProgress, + removalVisibilityProgress = removalVisibilityProgress, + ), + markPendingRemoval = { + if (!isPendingRemoval) { + isPendingRemoval = true + } + }, + ) +} + +@Composable +private fun ConversationMediaReviewPageCardContent( + attachment: ConversationComposerAttachmentUiState.Resolved, + page: Int, + pageHeight: Dp, + pageWidth: Dp, + pagerState: PagerState, + previewSize: IntSize, + contentState: ConversationMediaReviewPageCardContentState, + onAttachmentPreviewClick: (ConversationComposerAttachmentUiState.Resolved) -> Unit, + onAttachmentRemoveClick: () -> Unit, +) { + val pageCardModifier = Modifier + .fillMaxSize() + .padding(vertical = 4.dp) + .wrapContentSize(align = Alignment.Center) + .width(width = pageWidth) + .height(height = pageHeight) + .graphicsLayer { + val pageOffset = resolveReviewPageOffset( + page = page, + pagerState = pagerState, + ) + val pageScale = lerp( + start = 0.98f, + stop = 1f, + fraction = 1f - pageOffset, + ) + val removalScale = lerp( + start = 0.9f, + stop = 1f, + fraction = contentState.removalVisibilityProgress, + ) + alpha = contentState.removalVisibilityProgress + scaleX = pageScale * removalScale + scaleY = pageScale * removalScale + } + + Box( + modifier = pageCardModifier, + ) { + ConversationMediaReviewPreview( + modifier = Modifier + .fillMaxSize() + .clickable( + enabled = contentState.isPreviewEnabled, + onClick = { onAttachmentPreviewClick(attachment) }, + ), + attachment = attachment, + previewSize = previewSize, + ) + + if (contentState.isDeleteChipVisible) { + ConversationMediaReviewDeleteButton( + modifier = Modifier + .align(alignment = Alignment.TopEnd) + .zIndex(zIndex = 1f) + .padding( + top = 8.dp, + end = 8.dp, + ), + visibilityProgress = contentState.deleteChipVisibilityProgress, + onClick = onAttachmentRemoveClick, + ) + } + } +} + +@Composable +private fun ConversationMediaReviewPreview( + attachment: ConversationComposerAttachmentUiState.Resolved, + modifier: Modifier = Modifier, + previewSize: IntSize, +) { + val previewShape = RoundedCornerShape(28.dp) + + Surface( + modifier = modifier + .clip(previewShape), + shape = previewShape, + color = MaterialTheme + .colorScheme + .surfaceColorAtElevation(elevation = 6.dp) + .copy(alpha = 0.25f), + shadowElevation = 20.dp, + tonalElevation = 6.dp, + ) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + ConversationMediaThumbnail( + modifier = Modifier.fillMaxSize(), + contentUri = attachment.contentUri, + contentType = attachment.contentType, + size = previewSize, + contentScale = ContentScale.Crop, + backgroundColor = Color.Transparent, + ) + + if (ContentType.isVideoType(attachment.contentType)) { + ConversationMediaReviewVideoBadge() + } + } + } +} + +@Composable +private fun ConversationMediaReviewVideoBadge( + modifier: Modifier = Modifier, +) { + Surface( + modifier = modifier, + shape = CircleShape, + color = MaterialTheme.colorScheme.scrim.copy(alpha = 0.5f), + ) { + Icon( + modifier = Modifier.padding(12.dp), + imageVector = Icons.Rounded.PlayArrow, + contentDescription = null, + tint = MaterialTheme.colorScheme.inverseOnSurface, + ) + } +} + +@Composable +private fun ConversationMediaReviewDeleteButton( + modifier: Modifier = Modifier, + visibilityProgress: Float, + onClick: () -> Unit, +) { + val scale = lerp( + start = 0.9f, + stop = 1f, + fraction = visibilityProgress, + ) + + PickerOverlayBackgroundButton( + modifier = modifier.graphicsLayer { + alpha = visibilityProgress + scaleX = scale + scaleY = scale + }, + containerColor = MaterialTheme.colorScheme.scrim.copy(alpha = 0.5f), + contentDescription = stringResource( + id = R.string.conversation_media_picker_remove_attachment_content_description, + ), + buttonSize = 32.dp, + iconSize = 18.dp, + imageVector = Icons.Rounded.Close, + onClick = onClick, + ) +} + +private fun resolveReviewPageOffset( + page: Int, + pagerState: PagerState, +): Float { + val rawPageOffset = when { + pagerState.isScrollInProgress -> { + pagerState.currentPage - page + pagerState.currentPageOffsetFraction + } + + else -> (pagerState.settledPage - page).toFloat() + } + + return rawPageOffset.absoluteValue.coerceIn( + minimumValue = 0f, + maximumValue = 1f, + ) +} + +@Immutable +private data class ConversationMediaReviewPageCardState( + val contentState: ConversationMediaReviewPageCardContentState, + val markPendingRemoval: () -> Unit, +) + +@Immutable +private data class ConversationMediaReviewPageCardContentState( + val isPreviewEnabled: Boolean, + val isDeleteChipVisible: Boolean, + val deleteChipVisibilityProgress: Float, + val removalVisibilityProgress: Float, +) diff --git a/src/com/android/messaging/ui/conversation/v2/mediapicker/component/review/ConversationMediaReviewPagerState.kt b/src/com/android/messaging/ui/conversation/v2/mediapicker/component/review/ConversationMediaReviewPagerState.kt new file mode 100644 index 00000000..32760b5d --- /dev/null +++ b/src/com/android/messaging/ui/conversation/v2/mediapicker/component/review/ConversationMediaReviewPagerState.kt @@ -0,0 +1,177 @@ +package com.android.messaging.ui.conversation.v2.mediapicker.component.review + +import androidx.compose.animation.core.tween +import androidx.compose.foundation.pager.PagerState +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import com.android.messaging.ui.conversation.v2.composer.model.ConversationComposerAttachmentUiState +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList + +internal data class ConversationMediaReviewPagerState( + val attachmentContentUris: ImmutableList, + val currentAttachment: ConversationComposerAttachmentUiState.Resolved, + val pagerState: PagerState, + val visibleDeleteChipPage: Int?, +) + +@Composable +internal fun rememberConversationMediaReviewPagerState( + attachments: ImmutableList, + initiallyReviewedContentUri: String?, + reviewRequestSequence: Int, +): ConversationMediaReviewPagerState { + val attachmentContentUris = remember(attachments) { + attachments + .asSequence() + .map { it.contentUri } + .toImmutableList() + } + + val initiallyReviewedPage = resolveInitialReviewPage( + attachments = attachments, + initiallyReviewedContentUri = initiallyReviewedContentUri, + ) + + val pagerState = rememberPagerState( + initialPage = initiallyReviewedPage, + pageCount = { attachments.size }, + ) + + val reviewPagerCoordinator = remember { + ConversationMediaReviewPagerCoordinator( + initialReviewRequestSequence = reviewRequestSequence, + ) + } + val settledReviewPage = clampAttachmentPage( + page = pagerState.settledPage, + attachments = attachments, + ) + + LaunchedEffect( + attachmentContentUris, + initiallyReviewedContentUri, + reviewRequestSequence, + settledReviewPage, + ) { + reviewPagerCoordinator.syncTargetPage( + attachmentContentUris = attachmentContentUris, + attachments = attachments, + initiallyReviewedContentUri = initiallyReviewedContentUri, + reviewRequestSequence = reviewRequestSequence, + pagerState = pagerState, + ) + } + val visibleDeleteChipPage = resolveVisibleDeleteChipPage( + attachments = attachments, + pagerState = pagerState, + ) + + return ConversationMediaReviewPagerState( + attachmentContentUris = attachmentContentUris, + currentAttachment = attachments[settledReviewPage], + pagerState = pagerState, + visibleDeleteChipPage = visibleDeleteChipPage, + ) +} + +private class ConversationMediaReviewPagerCoordinator( + initialReviewRequestSequence: Int, +) { + private var pendingRequestedReviewContentUri: String? = null + private var latestReviewRequestSequence: Int = initialReviewRequestSequence + + suspend fun syncTargetPage( + attachmentContentUris: List, + attachments: List, + initiallyReviewedContentUri: String?, + reviewRequestSequence: Int, + pagerState: PagerState, + ) { + if (reviewRequestSequence != latestReviewRequestSequence) { + pendingRequestedReviewContentUri = initiallyReviewedContentUri + latestReviewRequestSequence = reviewRequestSequence + } + + val requestedAttachmentPage = resolveReviewedAttachmentPage( + attachmentContentUris = attachmentContentUris, + requestedReviewContentUri = pendingRequestedReviewContentUri, + ) + + val targetPage = requestedAttachmentPage ?: clampAttachmentPage( + page = pagerState.currentPage, + attachments = attachments, + ) + + if (pagerState.currentPage != targetPage) { + when { + requestedAttachmentPage != null -> { + pagerState.animateScrollToPage( + page = targetPage, + animationSpec = tween(durationMillis = 220), + ) + } + + else -> { + pagerState.scrollToPage(page = targetPage) + } + } + } + + if (requestedAttachmentPage != null) { + pendingRequestedReviewContentUri = null + } + } + + private fun resolveReviewedAttachmentPage( + attachmentContentUris: List, + requestedReviewContentUri: String?, + ): Int? { + return requestedReviewContentUri + ?.let(attachmentContentUris::indexOf) + ?.takeIf { it >= 0 } + } +} + +private fun resolveInitialReviewPage( + attachments: List, + initiallyReviewedContentUri: String?, +): Int { + return attachments + .indexOfFirst { it.contentUri == initiallyReviewedContentUri } + .takeIf { it >= 0 } + ?: attachments.lastIndex +} + +private fun clampAttachmentPage( + page: Int, + attachments: List, +): Int { + return page.coerceIn( + minimumValue = 0, + maximumValue = attachments.lastIndex, + ) +} + +private fun resolveVisibleDeleteChipPage( + attachments: List, + pagerState: PagerState, +): Int? { + val clampedCurrentPage = clampAttachmentPage( + page = pagerState.currentPage, + attachments = attachments, + ) + + val clampedSettledPage = clampAttachmentPage( + page = pagerState.settledPage, + attachments = attachments, + ) + + return when { + !pagerState.isScrollInProgress -> clampedCurrentPage + clampedCurrentPage == clampedSettledPage -> null + else -> clampedCurrentPage + } +} diff --git a/src/com/android/messaging/ui/conversation/v2/mediapicker/model/ConversationCapturedMedia.kt b/src/com/android/messaging/ui/conversation/v2/mediapicker/model/ConversationCapturedMedia.kt new file mode 100644 index 00000000..e35d3446 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/v2/mediapicker/model/ConversationCapturedMedia.kt @@ -0,0 +1,8 @@ +package com.android.messaging.ui.conversation.v2.mediapicker.model + +internal data class ConversationCapturedMedia( + val contentUri: String, + val contentType: String, + val width: Int? = null, + val height: Int? = null, +) diff --git a/src/com/android/messaging/ui/conversation/v2/mediapicker/model/ConversationMediaPickerUiState.kt b/src/com/android/messaging/ui/conversation/v2/mediapicker/model/ConversationMediaPickerUiState.kt new file mode 100644 index 00000000..83564182 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/v2/mediapicker/model/ConversationMediaPickerUiState.kt @@ -0,0 +1,12 @@ +package com.android.messaging.ui.conversation.v2.mediapicker.model + +import androidx.compose.runtime.Immutable +import com.android.messaging.data.media.model.ConversationMediaItem +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf + +@Immutable +internal data class ConversationMediaPickerUiState( + val galleryItems: ImmutableList = persistentListOf(), + val isLoadingGallery: Boolean = false, +) From 5dae62099e8f79df53b5174d378bef2346102860 Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Thu, 2 Apr 2026 13:46:44 +0300 Subject: [PATCH 19/99] Wire conversation screen to media picker --- .../conversation/v2/ConversationActivity.kt | 2 +- .../v2/screen/ConversationScreen.kt | 160 +++++++++++------ .../v2/screen/ConversationScreenEffects.kt | 53 ++++++ .../v2/screen/ConversationViewModel.kt | 168 ++++++++++++++---- .../ConversationMediaPickerOverlayUiState.kt | 15 ++ .../screen/model/ConversationScreenEffect.kt | 7 +- ...t => ConversationScreenScaffoldUiState.kt} | 2 +- 7 files changed, 313 insertions(+), 94 deletions(-) create mode 100644 src/com/android/messaging/ui/conversation/v2/screen/ConversationScreenEffects.kt create mode 100644 src/com/android/messaging/ui/conversation/v2/screen/model/ConversationMediaPickerOverlayUiState.kt rename src/com/android/messaging/ui/conversation/v2/screen/model/{ConversationUiState.kt => ConversationScreenScaffoldUiState.kt} (92%) diff --git a/src/com/android/messaging/ui/conversation/v2/ConversationActivity.kt b/src/com/android/messaging/ui/conversation/v2/ConversationActivity.kt index 76d0f155..71e04e54 100644 --- a/src/com/android/messaging/ui/conversation/v2/ConversationActivity.kt +++ b/src/com/android/messaging/ui/conversation/v2/ConversationActivity.kt @@ -8,9 +8,9 @@ import androidx.activity.enableEdgeToEdge import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue -import com.android.messaging.ui.core.AppTheme import com.android.messaging.ui.UIIntents import com.android.messaging.ui.conversation.v2.screen.ConversationScreen +import com.android.messaging.ui.core.AppTheme import dagger.hilt.android.AndroidEntryPoint @AndroidEntryPoint diff --git a/src/com/android/messaging/ui/conversation/v2/screen/ConversationScreen.kt b/src/com/android/messaging/ui/conversation/v2/screen/ConversationScreen.kt index 6cba9730..80973b31 100644 --- a/src/com/android/messaging/ui/conversation/v2/screen/ConversationScreen.kt +++ b/src/com/android/messaging/ui/conversation/v2/screen/ConversationScreen.kt @@ -1,9 +1,7 @@ package com.android.messaging.ui.conversation.v2.screen -import android.content.Intent -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyListState @@ -19,22 +17,23 @@ import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.platform.testTag import androidx.lifecycle.Lifecycle import androidx.lifecycle.compose.LifecycleEventEffect import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel -import com.android.messaging.ui.UIIntents -import com.android.messaging.ui.attachmentchooser.AttachmentChooserActivity import com.android.messaging.ui.conversation.v2.CONVERSATION_LOADING_INDICATOR_TEST_TAG -import com.android.messaging.ui.conversation.v2.composer.ui.ConversationComposeBar +import com.android.messaging.ui.conversation.v2.composer.model.ConversationComposerAttachmentUiState +import com.android.messaging.ui.conversation.v2.composer.ui.ConversationComposerSection +import com.android.messaging.ui.conversation.v2.mediapicker.ConversationMediaPickerOverlay +import com.android.messaging.ui.conversation.v2.mediapicker.rememberConversationMediaPickerState import com.android.messaging.ui.conversation.v2.messages.model.ConversationMessageUiModel import com.android.messaging.ui.conversation.v2.messages.model.ConversationMessagesUiState import com.android.messaging.ui.conversation.v2.messages.ui.ConversationMessages import com.android.messaging.ui.conversation.v2.metadata.ui.ConversationTopAppBar -import com.android.messaging.ui.conversation.v2.screen.model.ConversationScreenEffect -import com.android.messaging.ui.conversation.v2.screen.model.ConversationUiState +import com.android.messaging.ui.conversation.v2.screen.model.ConversationScreenScaffoldUiState +import kotlinx.collections.immutable.ImmutableList @Composable internal fun ConversationScreen( @@ -43,43 +42,82 @@ internal fun ConversationScreen( onNavigateBack: () -> Unit = {}, screenModel: ConversationScreenModel = viewModel(), ) { - val context = LocalContext.current - val attachmentChooserLauncher = rememberLauncherForActivityResult( - contract = ActivityResultContracts.StartActivityForResult(), - ) {} + val messageFieldFocusRequester = remember { + FocusRequester() + } + val mediaPickerState = rememberConversationMediaPickerState() + val scaffoldUiState by screenModel.scaffoldUiState.collectAsStateWithLifecycle() + val mediaPickerOverlayUiState by screenModel + .mediaPickerOverlayUiState + .collectAsStateWithLifecycle() LaunchedEffect(conversationId) { screenModel.onConversationChanged(conversationId = conversationId) } - LaunchedEffect(screenModel, context, attachmentChooserLauncher) { - screenModel.effects.collect { effect -> - when (effect) { - is ConversationScreenEffect.LaunchAttachmentChooser -> { - val chooserIntent = Intent( - context, - AttachmentChooserActivity::class.java, - ).apply { - putExtra( - UIIntents.UI_INTENT_EXTRA_CONVERSATION_ID, - effect.conversationId, - ) - } - - attachmentChooserLauncher.launch(chooserIntent) - } - } - } - } - LifecycleEventEffect(event = Lifecycle.Event.ON_STOP) { screenModel.persistDraft() } - val uiState by screenModel.uiState.collectAsStateWithLifecycle() + ConversationScreenEffects(screenModel = screenModel) + + Box( + modifier = modifier + .fillMaxSize(), + ) { + ConversationScreenScaffold( + modifier = Modifier + .fillMaxSize(), + conversationId = conversationId, + uiState = scaffoldUiState, + isMediaPickerOpen = mediaPickerState.isOpen, + messageFieldFocusRequester = messageFieldFocusRequester, + onNavigateBack = onNavigateBack, + onOpenMediaPicker = mediaPickerState::open, + onMessageTextChange = screenModel::onMessageTextChanged, + onPendingAttachmentRemove = screenModel::onRemovePendingAttachment, + onResolvedAttachmentClick = screenModel::onAttachmentClicked, + onResolvedAttachmentRemove = screenModel::onRemoveResolvedAttachment, + onSendClick = screenModel::onSendClick, + ) + + ConversationMediaPickerOverlay( + modifier = Modifier + .fillMaxSize(), + state = mediaPickerState, + mediaPickerUiState = mediaPickerOverlayUiState.mediaPicker, + attachments = mediaPickerOverlayUiState.attachments, + conversationTitle = mediaPickerOverlayUiState.conversationTitle, + isSendActionEnabled = mediaPickerOverlayUiState.isSendActionEnabled, + messageFieldFocusRequester = messageFieldFocusRequester, + onAttachmentPreviewClick = screenModel::onAttachmentClicked, + onAttachmentCaptionChange = screenModel::onUpdateAttachmentCaption, + onAttachmentRemove = screenModel::onRemoveResolvedAttachment, + onGalleryMediaConfirmed = screenModel::onGalleryMediaConfirmed, + onGalleryVisibilityChanged = screenModel::onGalleryVisibilityChanged, + onCapturedMediaReady = screenModel::onCapturedMediaReady, + onSendClick = screenModel::onSendClick, + ) + } +} +@Composable +private fun ConversationScreenScaffold( + modifier: Modifier = Modifier, + conversationId: String?, + uiState: ConversationScreenScaffoldUiState, + isMediaPickerOpen: Boolean, + messageFieldFocusRequester: FocusRequester, + onNavigateBack: () -> Unit, + onOpenMediaPicker: () -> Unit, + onMessageTextChange: (String) -> Unit, + onPendingAttachmentRemove: (String) -> Unit, + onResolvedAttachmentClick: (ConversationComposerAttachmentUiState.Resolved) -> Unit, + onResolvedAttachmentRemove: (String) -> Unit, + onSendClick: () -> Unit, +) { Scaffold( - modifier = modifier.fillMaxSize(), + modifier = modifier, topBar = { ConversationTopAppBar( metadata = uiState.metadata, @@ -87,23 +125,29 @@ internal fun ConversationScreen( ) }, bottomBar = { - ConversationComposeBar( - messageText = uiState.composer.messageText, - isMessageFieldEnabled = uiState.composer.isMessageFieldEnabled, - isAttachmentActionEnabled = uiState.composer.isAttachmentActionEnabled, - isSendActionEnabled = uiState.composer.isSendEnabled, - onAttachmentClick = screenModel::onAttachmentClick, - onMessageTextChange = { messageText -> - screenModel.onMessageTextChanged(text = messageText) - }, - onSendClick = screenModel::onSendClick, - ) + if (!isMediaPickerOpen) { + ConversationComposerSection( + attachments = uiState.composer.attachments, + messageText = uiState.composer.messageText, + isMessageFieldEnabled = uiState.composer.isMessageFieldEnabled, + isAttachmentActionEnabled = uiState.composer.isAttachmentActionEnabled, + isSendActionEnabled = uiState.composer.isSendEnabled, + messageFieldFocusRequester = messageFieldFocusRequester, + onAttachmentClick = onOpenMediaPicker, + onMessageTextChange = onMessageTextChange, + onPendingAttachmentRemove = onPendingAttachmentRemove, + onResolvedAttachmentClick = onResolvedAttachmentClick, + onResolvedAttachmentRemove = onResolvedAttachmentRemove, + onSendClick = onSendClick, + ) + } }, ) { contentPadding -> ConversationScreenContent( - modifier = Modifier.padding(paddingValues = contentPadding), + modifier = Modifier.fillMaxSize(), conversationId = conversationId, uiState = uiState, + contentPadding = contentPadding, ) } } @@ -112,12 +156,15 @@ internal fun ConversationScreen( private fun ConversationScreenContent( modifier: Modifier = Modifier, conversationId: String?, - uiState: ConversationUiState, + uiState: ConversationScreenScaffoldUiState, + contentPadding: PaddingValues, ) { when (val messagesState = uiState.messages) { is ConversationMessagesUiState.Loading -> { Box( - modifier = modifier.fillMaxSize(), + modifier = modifier + .fillMaxSize() + .padding(paddingValues = contentPadding), contentAlignment = Alignment.Center, ) { CircularProgressIndicator( @@ -138,7 +185,7 @@ private fun ConversationScreenContent( ) ConversationMessages( - modifier = modifier, + modifier = modifier.padding(paddingValues = contentPadding), messages = messagesState.messages, listState = messagesListState, ) @@ -149,14 +196,16 @@ private fun ConversationScreenContent( @Composable private fun AutoScrollToLatestMessage( conversationId: String?, - messages: List, + messages: ImmutableList, listState: LazyListState, ) { val latestMessage = messages.lastOrNull() val latestMessageId = latestMessage?.messageId + var previousLatestMessageId by remember(conversationId) { mutableStateOf(value = latestMessageId) } + var wasScrolledToLatestMessage by remember( conversationId, listState, @@ -172,10 +221,9 @@ private fun AutoScrollToLatestMessage( ) { snapshotFlow { isScrolledToLatestMessage(listState = listState) + }.collect { isScrolledToLatestMessage -> + wasScrolledToLatestMessage = isScrolledToLatestMessage } - .collect { isScrolledToLatestMessage -> - wasScrolledToLatestMessage = isScrolledToLatestMessage - } } LaunchedEffect( @@ -201,9 +249,7 @@ private fun AutoScrollToLatestMessage( } } -private fun isScrolledToLatestMessage( - listState: LazyListState, -): Boolean { +private fun isScrolledToLatestMessage(listState: LazyListState): Boolean { return listState.firstVisibleItemIndex == 0 && listState.firstVisibleItemScrollOffset == 0 } diff --git a/src/com/android/messaging/ui/conversation/v2/screen/ConversationScreenEffects.kt b/src/com/android/messaging/ui/conversation/v2/screen/ConversationScreenEffects.kt new file mode 100644 index 00000000..1a1dcd80 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/v2/screen/ConversationScreenEffects.kt @@ -0,0 +1,53 @@ +package com.android.messaging.ui.conversation.v2.screen + +import android.content.Context +import android.content.Intent +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.platform.LocalContext +import androidx.core.net.toUri +import com.android.messaging.R +import com.android.messaging.ui.conversation.v2.screen.model.ConversationScreenEffect +import com.android.messaging.util.UiUtils + +@Composable +internal fun ConversationScreenEffects( + screenModel: ConversationScreenModel, +) { + val context = LocalContext.current + + LaunchedEffect(screenModel, context) { + screenModel.effects.collect { effect -> + when (effect) { + is ConversationScreenEffect.OpenAttachmentPreview -> { + openAttachmentPreview( + context = context, + contentUri = effect.contentUri, + contentType = effect.contentType, + ) + } + + is ConversationScreenEffect.ShowMessage -> { + UiUtils.showToastAtBottom(effect.messageResId) + } + } + } + } +} + +private fun openAttachmentPreview( + context: Context, + contentUri: String, + contentType: String, +) { + val previewIntent = Intent(Intent.ACTION_VIEW).apply { + setDataAndType(contentUri.toUri(), contentType) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + + runCatching { + context.startActivity(previewIntent) + }.onFailure { + UiUtils.showToastAtBottom(R.string.activity_not_found_message) + } +} diff --git a/src/com/android/messaging/ui/conversation/v2/screen/ConversationViewModel.kt b/src/com/android/messaging/ui/conversation/v2/screen/ConversationViewModel.kt index 6df59e09..4b809289 100644 --- a/src/com/android/messaging/ui/conversation/v2/screen/ConversationViewModel.kt +++ b/src/com/android/messaging/ui/conversation/v2/screen/ConversationViewModel.kt @@ -3,14 +3,19 @@ package com.android.messaging.ui.conversation.v2.screen import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.android.messaging.data.media.model.ConversationMediaItem import com.android.messaging.di.core.DefaultDispatcher import com.android.messaging.ui.conversation.v2.composer.delegate.ConversationDraftDelegate -import com.android.messaging.ui.conversation.v2.composer.delegate.ConversationDraftEffect import com.android.messaging.ui.conversation.v2.composer.mapper.ConversationComposerUiStateMapper +import com.android.messaging.ui.conversation.v2.composer.model.ConversationComposerAttachmentUiState +import com.android.messaging.ui.conversation.v2.mediapicker.ConversationMediaPickerDelegate +import com.android.messaging.ui.conversation.v2.mediapicker.model.ConversationCapturedMedia import com.android.messaging.ui.conversation.v2.messages.delegate.ConversationMessagesDelegate import com.android.messaging.ui.conversation.v2.metadata.delegate.ConversationMetadataDelegate +import com.android.messaging.ui.conversation.v2.metadata.model.ConversationMetadataUiState +import com.android.messaging.ui.conversation.v2.screen.model.ConversationMediaPickerOverlayUiState import com.android.messaging.ui.conversation.v2.screen.model.ConversationScreenEffect -import com.android.messaging.ui.conversation.v2.screen.model.ConversationUiState +import com.android.messaging.ui.conversation.v2.screen.model.ConversationScreenScaffoldUiState import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject import kotlinx.coroutines.CoroutineDispatcher @@ -25,11 +30,25 @@ import kotlinx.coroutines.launch internal interface ConversationScreenModel { val effects: Flow - val uiState: StateFlow + val mediaPickerOverlayUiState: StateFlow + val scaffoldUiState: StateFlow fun onConversationChanged(conversationId: String?) + fun onAttachmentClicked( + attachment: ConversationComposerAttachmentUiState.Resolved, + ) + + fun onGalleryMediaConfirmed(mediaItems: List) fun onMessageTextChanged(text: String) - fun onAttachmentClick() + fun onGalleryVisibilityChanged(isVisible: Boolean) + fun onCapturedMediaReady(capturedMedia: ConversationCapturedMedia) + fun onRemovePendingAttachment(pendingAttachmentId: String) + fun onRemoveResolvedAttachment(contentUri: String) + fun onUpdateAttachmentCaption( + contentUri: String, + captionText: String, + ) + fun onSendClick() fun persistDraft() } @@ -38,6 +57,7 @@ internal interface ConversationScreenModel { internal class ConversationViewModel @Inject constructor( private val conversationDraftDelegate: ConversationDraftDelegate, private val conversationMessagesDelegate: ConversationMessagesDelegate, + private val conversationMediaPickerDelegate: ConversationMediaPickerDelegate, private val conversationMetadataDelegate: ConversationMetadataDelegate, private val conversationComposerUiStateMapper: ConversationComposerUiStateMapper, @param:DefaultDispatcher @@ -56,30 +76,79 @@ internal class ConversationViewModel @Inject constructor( override val effects = _effects.asSharedFlow() - override val uiState: StateFlow = combine( + private val composerUiState = combine( conversationMetadataDelegate.state, - conversationMessagesDelegate.state, conversationDraftDelegate.state, - ) { metadataState, messagesUiState, draft -> - return@combine ConversationUiState( + ) { metadataState, draftState -> + conversationComposerUiStateMapper.map( + draftState = draftState, + composerAvailability = metadataState.composerAvailability, + ) + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed( + stopTimeoutMillis = STATEFLOW_STOP_TIMEOUT_MILLIS, + ), + initialValue = conversationComposerUiStateMapper.map( + draftState = conversationDraftDelegate.state.value, + composerAvailability = conversationMetadataDelegate.state.value.composerAvailability, + ), + ) + + override val scaffoldUiState: StateFlow = combine( + conversationMetadataDelegate.state, + conversationMessagesDelegate.state, + composerUiState, + ) { metadataState, messagesUiState, composerUiState -> + ConversationScreenScaffoldUiState( metadata = metadataState, messages = messagesUiState, - composer = conversationComposerUiStateMapper.map( - draft = draft, - composerAvailability = metadataState.composerAvailability, - ), + composer = composerUiState, ) }.stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed( stopTimeoutMillis = STATEFLOW_STOP_TIMEOUT_MILLIS, ), - initialValue = ConversationUiState(), + initialValue = ConversationScreenScaffoldUiState( + metadata = conversationMetadataDelegate.state.value, + messages = conversationMessagesDelegate.state.value, + composer = composerUiState.value, + ), ) + override val mediaPickerOverlayUiState: StateFlow = + combine( + conversationMetadataDelegate.state, + conversationMediaPickerDelegate.state, + composerUiState, + ) { metadataState, mediaPickerUiState, composerUiState -> + val conversationTitle = when (metadataState) { + is ConversationMetadataUiState.Present -> metadataState.title + else -> null + } + + ConversationMediaPickerOverlayUiState( + mediaPicker = mediaPickerUiState, + attachments = composerUiState.attachments, + conversationTitle = conversationTitle, + isSendActionEnabled = composerUiState.isSendEnabled, + ) + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed( + stopTimeoutMillis = STATEFLOW_STOP_TIMEOUT_MILLIS, + ), + initialValue = ConversationMediaPickerOverlayUiState( + mediaPicker = conversationMediaPickerDelegate.state.value, + attachments = composerUiState.value.attachments, + conversationTitle = null, + isSendActionEnabled = composerUiState.value.isSendEnabled, + ), + ) + init { initializeDelegates() - bindDelegateEffects() } private fun initializeDelegates() { @@ -87,6 +156,10 @@ internal class ConversationViewModel @Inject constructor( scope = viewModelScope, conversationIdFlow = conversationIdFlow, ) + conversationMediaPickerDelegate.bind( + scope = viewModelScope, + conversationIdFlow = conversationIdFlow, + ) conversationMessagesDelegate.bind( scope = viewModelScope, conversationIdFlow = conversationIdFlow, @@ -95,6 +168,13 @@ internal class ConversationViewModel @Inject constructor( scope = viewModelScope, conversationIdFlow = conversationIdFlow, ) + bindDelegateEffects() + } + + private fun bindDelegateEffects() { + viewModelScope.launch(defaultDispatcher) { + conversationMediaPickerDelegate.effects.collect(_effects::emit) + } } override fun onConversationChanged(conversationId: String?) { @@ -103,12 +183,51 @@ internal class ConversationViewModel @Inject constructor( } } + override fun onAttachmentClicked( + attachment: ConversationComposerAttachmentUiState.Resolved, + ) { + viewModelScope.launch(defaultDispatcher) { + _effects.emit( + ConversationScreenEffect.OpenAttachmentPreview( + contentType = attachment.contentType, + contentUri = attachment.contentUri, + ), + ) + } + } + + override fun onGalleryMediaConfirmed(mediaItems: List) { + conversationMediaPickerDelegate.onGalleryMediaConfirmed(mediaItems = mediaItems) + } + override fun onMessageTextChanged(text: String) { conversationDraftDelegate.onMessageTextChanged(messageText = text) } - override fun onAttachmentClick() { - conversationDraftDelegate.onAttachmentClick() + override fun onGalleryVisibilityChanged(isVisible: Boolean) { + conversationMediaPickerDelegate.onGalleryVisibilityChanged(isVisible = isVisible) + } + + override fun onCapturedMediaReady(capturedMedia: ConversationCapturedMedia) { + conversationMediaPickerDelegate.onCapturedMediaReady(capturedMedia = capturedMedia) + } + + override fun onRemovePendingAttachment(pendingAttachmentId: String) { + conversationMediaPickerDelegate.onRemovePendingAttachment(pendingAttachmentId) + } + + override fun onRemoveResolvedAttachment(contentUri: String) { + conversationMediaPickerDelegate.onRemoveResolvedAttachment(contentUri = contentUri) + } + + override fun onUpdateAttachmentCaption( + contentUri: String, + captionText: String, + ) { + conversationDraftDelegate.updateAttachmentCaption( + contentUri = contentUri, + captionText = captionText, + ) } override fun onSendClick() { @@ -120,27 +239,12 @@ internal class ConversationViewModel @Inject constructor( } override fun onCleared() { + conversationMediaPickerDelegate.onScreenCleared() conversationDraftDelegate.flushDraft() super.onCleared() } - private fun bindDelegateEffects() { - viewModelScope.launch(defaultDispatcher) { - conversationDraftDelegate.effects.collect { effect -> - when (effect) { - is ConversationDraftEffect.LaunchAttachmentChooser -> { - _effects.emit( - ConversationScreenEffect.LaunchAttachmentChooser( - conversationId = effect.conversationId, - ), - ) - } - } - } - } - } - private companion object { private const val CONVERSATION_ID_KEY = "conversation_id" private const val STATEFLOW_STOP_TIMEOUT_MILLIS = 5_000L diff --git a/src/com/android/messaging/ui/conversation/v2/screen/model/ConversationMediaPickerOverlayUiState.kt b/src/com/android/messaging/ui/conversation/v2/screen/model/ConversationMediaPickerOverlayUiState.kt new file mode 100644 index 00000000..65180ff6 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/v2/screen/model/ConversationMediaPickerOverlayUiState.kt @@ -0,0 +1,15 @@ +package com.android.messaging.ui.conversation.v2.screen.model + +import androidx.compose.runtime.Immutable +import com.android.messaging.ui.conversation.v2.composer.model.ConversationComposerAttachmentUiState +import com.android.messaging.ui.conversation.v2.mediapicker.model.ConversationMediaPickerUiState +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf + +@Immutable +internal data class ConversationMediaPickerOverlayUiState( + val mediaPicker: ConversationMediaPickerUiState = ConversationMediaPickerUiState(), + val attachments: ImmutableList = persistentListOf(), + val conversationTitle: String? = null, + val isSendActionEnabled: Boolean = false, +) diff --git a/src/com/android/messaging/ui/conversation/v2/screen/model/ConversationScreenEffect.kt b/src/com/android/messaging/ui/conversation/v2/screen/model/ConversationScreenEffect.kt index e768469e..8a50bd6a 100644 --- a/src/com/android/messaging/ui/conversation/v2/screen/model/ConversationScreenEffect.kt +++ b/src/com/android/messaging/ui/conversation/v2/screen/model/ConversationScreenEffect.kt @@ -1,7 +1,8 @@ package com.android.messaging.ui.conversation.v2.screen.model internal sealed interface ConversationScreenEffect { - data class LaunchAttachmentChooser( - val conversationId: String, - ) : ConversationScreenEffect + data class OpenAttachmentPreview(val contentType: String, val contentUri: String) : + ConversationScreenEffect + + data class ShowMessage(val messageResId: Int) : ConversationScreenEffect } diff --git a/src/com/android/messaging/ui/conversation/v2/screen/model/ConversationUiState.kt b/src/com/android/messaging/ui/conversation/v2/screen/model/ConversationScreenScaffoldUiState.kt similarity index 92% rename from src/com/android/messaging/ui/conversation/v2/screen/model/ConversationUiState.kt rename to src/com/android/messaging/ui/conversation/v2/screen/model/ConversationScreenScaffoldUiState.kt index 7f0ad8e1..2cdc38a7 100644 --- a/src/com/android/messaging/ui/conversation/v2/screen/model/ConversationUiState.kt +++ b/src/com/android/messaging/ui/conversation/v2/screen/model/ConversationScreenScaffoldUiState.kt @@ -6,7 +6,7 @@ import com.android.messaging.ui.conversation.v2.messages.model.ConversationMessa import com.android.messaging.ui.conversation.v2.metadata.model.ConversationMetadataUiState @Immutable -internal data class ConversationUiState( +internal data class ConversationScreenScaffoldUiState( val metadata: ConversationMetadataUiState = ConversationMetadataUiState.Loading, val messages: ConversationMessagesUiState = ConversationMessagesUiState.Loading, val composer: ConversationComposerUiState = ConversationComposerUiState(), From 0fc207a0f4c290917b0de6b96bf7afca43423173 Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Thu, 2 Apr 2026 13:46:58 +0300 Subject: [PATCH 20/99] Clean up conversation message and metadata mapping --- .../repository/ConversationsRepository.kt | 35 +++++----- .../delegate/ConversationMessagesDelegate.kt | 5 +- .../ConversationMessageUiModelMapper.kt | 58 ++++++++-------- .../model/ConversationMessagesUiState.kt | 4 +- .../v2/messages/ui/ConversationMessage.kt | 66 ++++++++----------- .../delegate/ConversationMetadataDelegate.kt | 13 ++-- .../ConversationMetadataUiStateMapper.kt | 3 +- .../v2/metadata/ui/ConversationTopAppBar.kt | 6 +- .../messaging/util/db/ext/CursorExtensions.kt | 18 +++++ 9 files changed, 110 insertions(+), 98 deletions(-) create mode 100644 src/com/android/messaging/util/db/ext/CursorExtensions.kt diff --git a/src/com/android/messaging/data/conversation/repository/ConversationsRepository.kt b/src/com/android/messaging/data/conversation/repository/ConversationsRepository.kt index a13a092f..35ccc967 100644 --- a/src/com/android/messaging/data/conversation/repository/ConversationsRepository.kt +++ b/src/com/android/messaging/data/conversation/repository/ConversationsRepository.kt @@ -9,9 +9,11 @@ import com.android.messaging.datamodel.DatabaseHelper.ConversationColumns import com.android.messaging.datamodel.MessagingContentProvider import com.android.messaging.datamodel.data.ConversationListItemData import com.android.messaging.datamodel.data.ConversationMessageData -import com.android.messaging.di.core.DefaultDispatcher import com.android.messaging.di.core.IoDispatcher import com.android.messaging.util.db.ReversedCursor +import com.android.messaging.util.db.ext.getInt +import com.android.messaging.util.db.ext.getStringOrEmpty +import javax.inject.Inject import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.flow.Flow @@ -19,7 +21,6 @@ import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.conflate import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map -import javax.inject.Inject internal interface ConversationsRepository { fun getConversationMetadata(conversationId: String): Flow @@ -28,8 +29,6 @@ internal interface ConversationsRepository { internal class ConversationsRepositoryImpl @Inject constructor( private val contentResolver: ContentResolver, - @param:DefaultDispatcher - private val defaultDispatcher: CoroutineDispatcher, @param:IoDispatcher private val ioDispatcher: CoroutineDispatcher, ) : ConversationsRepository { @@ -38,19 +37,19 @@ internal class ConversationsRepositoryImpl @Inject constructor( val uri = MessagingContentProvider.buildConversationMetadataUri(conversationId) return observeUri(uri = uri) - .flowOn(defaultDispatcher) .map { queryConversationMetadata(uri = uri) } .flowOn(ioDispatcher) } - override fun getConversationMessages(conversationId: String): Flow> { + override fun getConversationMessages( + conversationId: String, + ): Flow> { val uri = MessagingContentProvider.buildConversationMessagesUri(conversationId) return observeUri(uri = uri) .conflate() - .flowOn(defaultDispatcher) .map { queryConversationMessages(uri = uri) } @@ -89,18 +88,12 @@ internal class ConversationsRepositoryImpl @Inject constructor( } ConversationMetadata( - conversationName = cursor.getString( - cursor.getColumnIndexOrThrow(ConversationColumns.NAME), - ).orEmpty(), - selfParticipantId = cursor.getString( - cursor.getColumnIndexOrThrow(ConversationColumns.CURRENT_SELF_ID), - ).orEmpty(), - isGroupConversation = cursor.getInt( - cursor.getColumnIndexOrThrow(ConversationColumns.PARTICIPANT_COUNT), - ) > 1, - participantCount = cursor.getInt( - cursor.getColumnIndexOrThrow(ConversationColumns.PARTICIPANT_COUNT), + conversationName = cursor.getStringOrEmpty(ConversationColumns.NAME), + selfParticipantId = cursor.getStringOrEmpty( + ConversationColumns.CURRENT_SELF_ID ), + isGroupConversation = cursor.getInt(ConversationColumns.PARTICIPANT_COUNT) > 1, + participantCount = cursor.getInt(ConversationColumns.PARTICIPANT_COUNT), composerAvailability = ConversationComposerAvailability.editable(), ) } @@ -111,12 +104,14 @@ internal class ConversationsRepositoryImpl @Inject constructor( .query( uri, ConversationMessageData.getProjection(), - null, null, null, + null, + null, + null, ) ?.use { rawCursor -> val reversedCursor = ReversedCursor(cursor = rawCursor) - buildList { + buildList(capacity = rawCursor.count) { while (reversedCursor.moveToNext()) { add(ConversationMessageData().apply { bind(reversedCursor) }) } diff --git a/src/com/android/messaging/ui/conversation/v2/messages/delegate/ConversationMessagesDelegate.kt b/src/com/android/messaging/ui/conversation/v2/messages/delegate/ConversationMessagesDelegate.kt index 07e1fc9e..d512e320 100644 --- a/src/com/android/messaging/ui/conversation/v2/messages/delegate/ConversationMessagesDelegate.kt +++ b/src/com/android/messaging/ui/conversation/v2/messages/delegate/ConversationMessagesDelegate.kt @@ -5,6 +5,7 @@ import com.android.messaging.di.core.DefaultDispatcher import com.android.messaging.ui.conversation.v2.common.ConversationScreenDelegate import com.android.messaging.ui.conversation.v2.messages.mapper.ConversationMessageUiModelMapper import com.android.messaging.ui.conversation.v2.messages.model.ConversationMessagesUiState +import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow @@ -57,7 +58,9 @@ internal class ConversationMessagesDelegateImpl @Inject constructor( .map { messages -> ConversationMessagesUiState.Present( messages = messages - .mapNotNull(conversationMessageUiModelMapper::map), + .asSequence() + .map(conversationMessageUiModelMapper::map) + .toImmutableList(), ) } .flowOn(defaultDispatcher) diff --git a/src/com/android/messaging/ui/conversation/v2/messages/mapper/ConversationMessageUiModelMapper.kt b/src/com/android/messaging/ui/conversation/v2/messages/mapper/ConversationMessageUiModelMapper.kt index 577e734f..27f1f8e4 100644 --- a/src/com/android/messaging/ui/conversation/v2/messages/mapper/ConversationMessageUiModelMapper.kt +++ b/src/com/android/messaging/ui/conversation/v2/messages/mapper/ConversationMessageUiModelMapper.kt @@ -1,21 +1,22 @@ package com.android.messaging.ui.conversation.v2.messages.mapper -import android.util.Log import com.android.messaging.datamodel.data.ConversationMessageData import com.android.messaging.datamodel.data.MessageData import com.android.messaging.datamodel.data.MessagePartData import com.android.messaging.ui.conversation.v2.messages.model.ConversationMessagePartUiModel import com.android.messaging.ui.conversation.v2.messages.model.ConversationMessageUiModel +import com.android.messaging.ui.conversation.v2.messages.model.ConversationMessageUiModel.Status +import com.android.messaging.util.LogUtil import javax.inject.Inject internal interface ConversationMessageUiModelMapper { - fun map(data: ConversationMessageData): ConversationMessageUiModel? + fun map(data: ConversationMessageData): ConversationMessageUiModel } -internal class ConversationMessageUiModelMapperImpl @Inject constructor() : ConversationMessageUiModelMapper { +internal class ConversationMessageUiModelMapperImpl @Inject constructor() : + ConversationMessageUiModelMapper { - // TODO: Check if empty default values are ok - override fun map(data: ConversationMessageData): ConversationMessageUiModel? { + override fun map(data: ConversationMessageData): ConversationMessageUiModel { return ConversationMessageUiModel( messageId = data.messageId ?: "", conversationId = data.conversationId ?: "", @@ -50,47 +51,44 @@ internal class ConversationMessageUiModelMapperImpl @Inject constructor() : Conv ) } - private fun mapStatus(javaStatus: Int): ConversationMessageUiModel.Status { + private fun mapStatus(javaStatus: Int): Status { return when (javaStatus) { - MessageData.BUGLE_STATUS_UNKNOWN -> ConversationMessageUiModel.Status.Unknown - - MessageData.BUGLE_STATUS_OUTGOING_COMPLETE -> ConversationMessageUiModel.Status.Outgoing.Complete - MessageData.BUGLE_STATUS_OUTGOING_DELIVERED -> ConversationMessageUiModel.Status.Outgoing.Delivered - MessageData.BUGLE_STATUS_OUTGOING_DRAFT -> ConversationMessageUiModel.Status.Outgoing.Draft - MessageData.BUGLE_STATUS_OUTGOING_YET_TO_SEND -> ConversationMessageUiModel.Status.Outgoing.YetToSend - MessageData.BUGLE_STATUS_OUTGOING_SENDING -> ConversationMessageUiModel.Status.Outgoing.Sending - MessageData.BUGLE_STATUS_OUTGOING_RESENDING -> ConversationMessageUiModel.Status.Outgoing.Resending - MessageData.BUGLE_STATUS_OUTGOING_AWAITING_RETRY -> ConversationMessageUiModel.Status.Outgoing.AwaitingRetry - MessageData.BUGLE_STATUS_OUTGOING_FAILED -> ConversationMessageUiModel.Status.Outgoing.Failed + MessageData.BUGLE_STATUS_UNKNOWN -> Status.Unknown + + MessageData.BUGLE_STATUS_OUTGOING_COMPLETE -> Status.Outgoing.Complete + MessageData.BUGLE_STATUS_OUTGOING_DELIVERED -> Status.Outgoing.Delivered + MessageData.BUGLE_STATUS_OUTGOING_DRAFT -> Status.Outgoing.Draft + MessageData.BUGLE_STATUS_OUTGOING_YET_TO_SEND -> Status.Outgoing.YetToSend + MessageData.BUGLE_STATUS_OUTGOING_SENDING -> Status.Outgoing.Sending + MessageData.BUGLE_STATUS_OUTGOING_RESENDING -> Status.Outgoing.Resending + MessageData.BUGLE_STATUS_OUTGOING_AWAITING_RETRY -> Status.Outgoing.AwaitingRetry + MessageData.BUGLE_STATUS_OUTGOING_FAILED -> Status.Outgoing.Failed MessageData.BUGLE_STATUS_OUTGOING_FAILED_EMERGENCY_NUMBER -> - ConversationMessageUiModel.Status.Outgoing.FailedEmergencyNumber + Status.Outgoing.FailedEmergencyNumber - MessageData.BUGLE_STATUS_INCOMING_COMPLETE -> ConversationMessageUiModel.Status.Incoming.Complete + MessageData.BUGLE_STATUS_INCOMING_COMPLETE -> Status.Incoming.Complete MessageData.BUGLE_STATUS_INCOMING_YET_TO_MANUAL_DOWNLOAD -> - ConversationMessageUiModel.Status.Incoming.YetToManualDownload + Status.Incoming.YetToManualDownload MessageData.BUGLE_STATUS_INCOMING_RETRYING_MANUAL_DOWNLOAD -> - ConversationMessageUiModel.Status.Incoming.RetryingManualDownload + Status.Incoming.RetryingManualDownload MessageData.BUGLE_STATUS_INCOMING_MANUAL_DOWNLOADING -> - ConversationMessageUiModel.Status.Incoming.ManualDownloading + Status.Incoming.ManualDownloading MessageData.BUGLE_STATUS_INCOMING_RETRYING_AUTO_DOWNLOAD -> - ConversationMessageUiModel.Status.Incoming.RetryingAutoDownload + Status.Incoming.RetryingAutoDownload - MessageData.BUGLE_STATUS_INCOMING_AUTO_DOWNLOADING -> - ConversationMessageUiModel.Status.Incoming.AutoDownloading - - MessageData.BUGLE_STATUS_INCOMING_DOWNLOAD_FAILED -> - ConversationMessageUiModel.Status.Incoming.DownloadFailed + MessageData.BUGLE_STATUS_INCOMING_AUTO_DOWNLOADING -> Status.Incoming.AutoDownloading + MessageData.BUGLE_STATUS_INCOMING_DOWNLOAD_FAILED -> Status.Incoming.DownloadFailed MessageData.BUGLE_STATUS_INCOMING_EXPIRED_OR_NOT_AVAILABLE -> - ConversationMessageUiModel.Status.Incoming.ExpiredOrNotAvailable + Status.Incoming.ExpiredOrNotAvailable else -> { - Log.e(LOG_TAG, "mapStatus: unexpected value=$javaStatus") + LogUtil.e(LOG_TAG, "mapStatus: unexpected value=$javaStatus") - ConversationMessageUiModel.Status.Unknown + Status.Unknown } } } diff --git a/src/com/android/messaging/ui/conversation/v2/messages/model/ConversationMessagesUiState.kt b/src/com/android/messaging/ui/conversation/v2/messages/model/ConversationMessagesUiState.kt index 51fa5317..2984638c 100644 --- a/src/com/android/messaging/ui/conversation/v2/messages/model/ConversationMessagesUiState.kt +++ b/src/com/android/messaging/ui/conversation/v2/messages/model/ConversationMessagesUiState.kt @@ -1,6 +1,8 @@ package com.android.messaging.ui.conversation.v2.messages.model import androidx.compose.runtime.Immutable +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf @Immutable internal sealed interface ConversationMessagesUiState { @@ -10,6 +12,6 @@ internal sealed interface ConversationMessagesUiState { @Immutable data class Present( - val messages: List = emptyList(), + val messages: ImmutableList = persistentListOf(), ) : ConversationMessagesUiState } diff --git a/src/com/android/messaging/ui/conversation/v2/messages/ui/ConversationMessage.kt b/src/com/android/messaging/ui/conversation/v2/messages/ui/ConversationMessage.kt index 7c6be92b..a3049b4d 100644 --- a/src/com/android/messaging/ui/conversation/v2/messages/ui/ConversationMessage.kt +++ b/src/com/android/messaging/ui/conversation/v2/messages/ui/ConversationMessage.kt @@ -28,6 +28,7 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.android.messaging.R import com.android.messaging.ui.conversation.v2.messages.model.ConversationMessageUiModel +import com.android.messaging.ui.conversation.v2.messages.model.ConversationMessageUiModel.Status private const val MESSAGE_BUBBLE_MAX_WIDTH_DP = 360 private const val MESSAGE_BUBBLE_WIDTH_FRACTION = 0.8f @@ -117,8 +118,8 @@ private fun rememberConversationMessagePresentation( message.canClusterWithPrevious, ) { message.isIncoming && - !message.senderDisplayName.isNullOrBlank() && - !message.canClusterWithPrevious + !message.senderDisplayName.isNullOrBlank() && + !message.canClusterWithPrevious } return remember( @@ -136,7 +137,9 @@ private fun rememberConversationMessagePresentation( } } -private fun messageHorizontalArrangement(message: ConversationMessageUiModel): Arrangement.Horizontal { +private fun messageHorizontalArrangement( + message: ConversationMessageUiModel, +): Arrangement.Horizontal { return when { message.isIncoming -> Arrangement.Start else -> Arrangement.End @@ -357,49 +360,34 @@ private fun buildMessageMetadataText( return "$formattedTime \u2022 $statusText" } -private fun messageStatusTextResourceId(status: ConversationMessageUiModel.Status): Int? { +private fun messageStatusTextResourceId(status: Status): Int? { return when (status) { - ConversationMessageUiModel.Status.Unknown -> null - ConversationMessageUiModel.Status.Outgoing.Complete -> null - ConversationMessageUiModel.Status.Outgoing.Delivered -> R.string.delivered_status_content_description - - ConversationMessageUiModel.Status.Outgoing.Draft -> null - ConversationMessageUiModel.Status.Outgoing.YetToSend -> null - ConversationMessageUiModel.Status.Outgoing.Sending -> R.string.message_status_sending - - ConversationMessageUiModel.Status.Outgoing.Resending -> R.string.message_status_send_retrying - - ConversationMessageUiModel.Status.Outgoing.AwaitingRetry -> R.string.message_status_failed - - ConversationMessageUiModel.Status.Outgoing.Failed -> R.string.message_status_failed - - ConversationMessageUiModel.Status.Outgoing.FailedEmergencyNumber -> R.string.message_status_failed - - ConversationMessageUiModel.Status.Incoming.Complete -> null - ConversationMessageUiModel.Status.Incoming.YetToManualDownload -> R.string.message_status_download - - ConversationMessageUiModel.Status.Incoming.RetryingManualDownload -> R.string.message_status_downloading - - ConversationMessageUiModel.Status.Incoming.ManualDownloading -> R.string.message_status_downloading - - ConversationMessageUiModel.Status.Incoming.RetryingAutoDownload -> R.string.message_status_downloading - - ConversationMessageUiModel.Status.Incoming.AutoDownloading -> R.string.message_status_downloading - - ConversationMessageUiModel.Status.Incoming.DownloadFailed -> R.string.message_status_download_failed - - ConversationMessageUiModel.Status.Incoming.ExpiredOrNotAvailable -> R.string.message_status_download_error + Status.Outgoing.Delivered -> R.string.delivered_status_content_description + Status.Outgoing.Sending -> R.string.message_status_sending + Status.Outgoing.Resending -> R.string.message_status_send_retrying + Status.Outgoing.AwaitingRetry -> R.string.message_status_failed + Status.Outgoing.Failed -> R.string.message_status_failed + Status.Outgoing.FailedEmergencyNumber -> R.string.message_status_failed + Status.Incoming.YetToManualDownload -> R.string.message_status_download + Status.Incoming.RetryingManualDownload -> R.string.message_status_downloading + Status.Incoming.ManualDownloading -> R.string.message_status_downloading + Status.Incoming.RetryingAutoDownload -> R.string.message_status_downloading + Status.Incoming.AutoDownloading -> R.string.message_status_downloading + Status.Incoming.DownloadFailed -> R.string.message_status_download_failed + Status.Incoming.ExpiredOrNotAvailable -> R.string.message_status_download_error + else -> null } } @Composable private fun messageMetadataColor(message: ConversationMessageUiModel): Color { return when (message.status) { - ConversationMessageUiModel.Status.Outgoing.AwaitingRetry, - ConversationMessageUiModel.Status.Outgoing.Failed, - ConversationMessageUiModel.Status.Outgoing.FailedEmergencyNumber, - ConversationMessageUiModel.Status.Incoming.DownloadFailed, - ConversationMessageUiModel.Status.Incoming.ExpiredOrNotAvailable -> MaterialTheme.colorScheme.error + Status.Outgoing.AwaitingRetry, + Status.Outgoing.Failed, + Status.Outgoing.FailedEmergencyNumber, + Status.Incoming.DownloadFailed, + Status.Incoming.ExpiredOrNotAvailable, + -> MaterialTheme.colorScheme.error else -> MaterialTheme.colorScheme.onSurfaceVariant } diff --git a/src/com/android/messaging/ui/conversation/v2/metadata/delegate/ConversationMetadataDelegate.kt b/src/com/android/messaging/ui/conversation/v2/metadata/delegate/ConversationMetadataDelegate.kt index 0bb72621..72e502ed 100644 --- a/src/com/android/messaging/ui/conversation/v2/metadata/delegate/ConversationMetadataDelegate.kt +++ b/src/com/android/messaging/ui/conversation/v2/metadata/delegate/ConversationMetadataDelegate.kt @@ -5,15 +5,16 @@ import com.android.messaging.di.core.DefaultDispatcher import com.android.messaging.ui.conversation.v2.common.ConversationScreenDelegate import com.android.messaging.ui.conversation.v2.metadata.mapper.ConversationMetadataUiStateMapper import com.android.messaging.ui.conversation.v2.metadata.model.ConversationMetadataUiState +import javax.inject.Inject import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch -import javax.inject.Inject internal interface ConversationMetadataDelegate : ConversationScreenDelegate @@ -53,12 +54,14 @@ internal class ConversationMetadataDelegateImpl @Inject constructor( conversationsRepository .getConversationMetadata(conversationId = conversationId) .map { metadata -> - if (metadata == null) { - return@map ConversationMetadataUiState.Unavailable + when { + metadata != null -> { + conversationMetadataUiStateMapper.map(metadata = metadata) + } + else -> ConversationMetadataUiState.Unavailable } - - return@map conversationMetadataUiStateMapper.map(metadata = metadata) } + .flowOn(defaultDispatcher) .collect { currentMetadataState -> _state.value = currentMetadataState } diff --git a/src/com/android/messaging/ui/conversation/v2/metadata/mapper/ConversationMetadataUiStateMapper.kt b/src/com/android/messaging/ui/conversation/v2/metadata/mapper/ConversationMetadataUiStateMapper.kt index 3d05c220..aede8f98 100644 --- a/src/com/android/messaging/ui/conversation/v2/metadata/mapper/ConversationMetadataUiStateMapper.kt +++ b/src/com/android/messaging/ui/conversation/v2/metadata/mapper/ConversationMetadataUiStateMapper.kt @@ -8,7 +8,8 @@ internal interface ConversationMetadataUiStateMapper { fun map(metadata: ConversationMetadata): ConversationMetadataUiState } -internal class ConversationMetadataUiStateMapperImpl @Inject constructor() : ConversationMetadataUiStateMapper { +internal class ConversationMetadataUiStateMapperImpl @Inject constructor() : + ConversationMetadataUiStateMapper { override fun map(metadata: ConversationMetadata): ConversationMetadataUiState { return ConversationMetadataUiState.Present( diff --git a/src/com/android/messaging/ui/conversation/v2/metadata/ui/ConversationTopAppBar.kt b/src/com/android/messaging/ui/conversation/v2/metadata/ui/ConversationTopAppBar.kt index eec389ee..d261c025 100644 --- a/src/com/android/messaging/ui/conversation/v2/metadata/ui/ConversationTopAppBar.kt +++ b/src/com/android/messaging/ui/conversation/v2/metadata/ui/ConversationTopAppBar.kt @@ -108,7 +108,9 @@ private fun ConversationTopAppBarTitle( presentation: ConversationTopAppBarPresentation, ) { Row( - horizontalArrangement = Arrangement.spacedBy(space = CONVERSATION_TOP_APP_BAR_TITLE_SPACING), + horizontalArrangement = Arrangement.spacedBy( + space = CONVERSATION_TOP_APP_BAR_TITLE_SPACING + ), verticalAlignment = Alignment.CenterVertically, ) { ConversationAvatar( @@ -195,6 +197,7 @@ private fun conversationTitle( ): String { return when (metadata) { ConversationMetadataUiState.Loading -> stringResource(id = R.string.app_name) + ConversationMetadataUiState.Unavailable -> stringResource(id = R.string.app_name) is ConversationMetadataUiState.Present -> { @@ -222,6 +225,7 @@ private fun conversationSubtitle( ): String? { return when (metadata) { ConversationMetadataUiState.Loading -> stringResource(id = R.string.loading_messages) + ConversationMetadataUiState.Unavailable -> null is ConversationMetadataUiState.Present -> { diff --git a/src/com/android/messaging/util/db/ext/CursorExtensions.kt b/src/com/android/messaging/util/db/ext/CursorExtensions.kt new file mode 100644 index 00000000..5c14ede5 --- /dev/null +++ b/src/com/android/messaging/util/db/ext/CursorExtensions.kt @@ -0,0 +1,18 @@ +package com.android.messaging.util.db.ext + +import android.database.Cursor +import androidx.core.database.getStringOrNull + +fun Cursor.getStringOrNull(columnName: String): String? { + return getColumnIndexOrThrow(columnName) + .let(::getStringOrNull) +} + +fun Cursor.getStringOrEmpty(columnName: String): String { + return getStringOrNull(columnName = columnName).orEmpty() +} + +fun Cursor.getInt(columnName: String): Int { + return getColumnIndexOrThrow(columnName) + .let(::getInt) +} From 0ff0fb1dbbbf8d0200f51ecbdf17bc10f1dc103b Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Thu, 2 Apr 2026 13:47:06 +0300 Subject: [PATCH 21/99] Polish image and cursor utilities --- .../messaging/di/core/CoreProvidesModule.kt | 2 +- .../android/messaging/util/ImageUtils.java | 23 +++++++++++++++++-- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/src/com/android/messaging/di/core/CoreProvidesModule.kt b/src/com/android/messaging/di/core/CoreProvidesModule.kt index 567e50c1..d22054c4 100644 --- a/src/com/android/messaging/di/core/CoreProvidesModule.kt +++ b/src/com/android/messaging/di/core/CoreProvidesModule.kt @@ -8,11 +8,11 @@ import dagger.Reusable import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob -import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) diff --git a/src/com/android/messaging/util/ImageUtils.java b/src/com/android/messaging/util/ImageUtils.java index 70ca7b66..bbd9dc2b 100644 --- a/src/com/android/messaging/util/ImageUtils.java +++ b/src/com/android/messaging/util/ImageUtils.java @@ -43,6 +43,7 @@ import com.android.messaging.util.exif.ExifInterface; import com.google.common.annotations.VisibleForTesting; +import java.io.BufferedInputStream; import java.io.ByteArrayOutputStream; import java.io.File; import java.io.FileNotFoundException; @@ -268,8 +269,11 @@ public static int getOrientation(final InputStream inputStream) { return orientation; } final ExifInterface exifInterface = new ExifInterface(); - try (inputStream) { - exifInterface.readExif(inputStream); + try (final InputStream bufferedInputStream = wrapForExifHeaderProbe(inputStream)) { + if (!isJpegStream(bufferedInputStream)) { + return orientation; + } + exifInterface.readExif(bufferedInputStream); } catch (IOException e) { LogUtil.e(TAG, "getOrientation", e); } @@ -281,6 +285,21 @@ public static int getOrientation(final InputStream inputStream) { return orientation; } + private static InputStream wrapForExifHeaderProbe(final InputStream inputStream) { + if (inputStream.markSupported()) { + return inputStream; + } + return new BufferedInputStream(inputStream); + } + + private static boolean isJpegStream(final InputStream inputStream) throws IOException { + inputStream.mark(2); + final int firstByte = inputStream.read(); + final int secondByte = inputStream.read(); + inputStream.reset(); + return firstByte == 0xFF && secondByte == 0xD8; + } + /** * Returns whether the resource is a GIF image. */ From dbc4674708712a36eb76207799b0245964ea092d Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Thu, 9 Apr 2026 14:15:09 +0300 Subject: [PATCH 22/99] Add conversation Compose UI test hooks --- app/build.gradle.kts | 1 + config/detekt/detekt.yml | 2 + gradle/libs.versions.toml | 1 + gradle/verification-metadata.xml | 79 ++++ .../conversation/v2/ConversationTestTags.kt | 14 + .../ConversationComposerUiStateMapper.kt | 2 +- .../ui/ConversationAttachmentPreview.kt | 42 +- .../v2/composer/ui/ConversationComposeBar.kt | 4 + .../ConversationMediaPickerOverlay.kt | 10 +- .../delegate/ConversationMessagesDelegate.kt | 4 +- .../ConversationMessageUiModelMapper.kt | 11 +- .../model/ConversationMessagePartUiModel.kt | 13 - .../ConversationAttachmentOpenAction.kt | 18 + .../ConversationAttachmentSections.kt | 27 ++ .../ConversationInlineAttachment.kt | 20 + .../ConversationMessageAttachment.kt | 28 ++ .../message/ConversationMessageContent.kt | 15 + .../message/ConversationMessagePartUiModel.kt | 53 +++ .../ConversationMessageUiModel.kt | 3 +- .../ConversationMessagesUiState.kt | 2 +- .../messages/ui/ConversationMessageDisplay.kt | 34 -- .../v2/messages/ui/ConversationMessages.kt | 50 +-- .../ConversationAttachmentActionDispatcher.kt | 50 +++ .../ConversationAttachmentSectionsBuilder.kt | 185 +++++++++ .../ConversationInlineAttachmentRow.kt | 131 ++++++ .../ConversationMessageAttachments.kt | 63 +++ .../ConversationVisualAttachments.kt | 379 ++++++++++++++++++ .../ui/{ => message}/ConversationMessage.kt | 332 ++++++++++++--- .../ConversationMessageContentBuilder.kt | 173 ++++++++ .../ConversationMessageDateFormatting.kt | 70 ++++ .../ui/text/ConversationMessageText.kt | 106 +++++ .../ConversationMessageTextLinkExtractor.kt | 117 ++++++ .../v2/screen/ConversationScreen.kt | 14 +- .../v2/screen/ConversationScreenEffects.kt | 121 +++++- .../v2/screen/ConversationViewModel.kt | 46 +++ .../screen/model/ConversationScreenEffect.kt | 16 +- .../ConversationScreenScaffoldUiState.kt | 2 +- 37 files changed, 2068 insertions(+), 170 deletions(-) delete mode 100644 src/com/android/messaging/ui/conversation/v2/messages/model/ConversationMessagePartUiModel.kt create mode 100644 src/com/android/messaging/ui/conversation/v2/messages/model/attachment/ConversationAttachmentOpenAction.kt create mode 100644 src/com/android/messaging/ui/conversation/v2/messages/model/attachment/ConversationAttachmentSections.kt create mode 100644 src/com/android/messaging/ui/conversation/v2/messages/model/attachment/ConversationInlineAttachment.kt create mode 100644 src/com/android/messaging/ui/conversation/v2/messages/model/attachment/ConversationMessageAttachment.kt create mode 100644 src/com/android/messaging/ui/conversation/v2/messages/model/message/ConversationMessageContent.kt create mode 100644 src/com/android/messaging/ui/conversation/v2/messages/model/message/ConversationMessagePartUiModel.kt rename src/com/android/messaging/ui/conversation/v2/messages/model/{ => message}/ConversationMessageUiModel.kt (96%) rename src/com/android/messaging/ui/conversation/v2/messages/model/{ => message}/ConversationMessagesUiState.kt (86%) delete mode 100644 src/com/android/messaging/ui/conversation/v2/messages/ui/ConversationMessageDisplay.kt create mode 100644 src/com/android/messaging/ui/conversation/v2/messages/ui/attachment/ConversationAttachmentActionDispatcher.kt create mode 100644 src/com/android/messaging/ui/conversation/v2/messages/ui/attachment/ConversationAttachmentSectionsBuilder.kt create mode 100644 src/com/android/messaging/ui/conversation/v2/messages/ui/attachment/ConversationInlineAttachmentRow.kt create mode 100644 src/com/android/messaging/ui/conversation/v2/messages/ui/attachment/ConversationMessageAttachments.kt create mode 100644 src/com/android/messaging/ui/conversation/v2/messages/ui/attachment/ConversationVisualAttachments.kt rename src/com/android/messaging/ui/conversation/v2/messages/ui/{ => message}/ConversationMessage.kt (50%) create mode 100644 src/com/android/messaging/ui/conversation/v2/messages/ui/message/ConversationMessageContentBuilder.kt create mode 100644 src/com/android/messaging/ui/conversation/v2/messages/ui/message/ConversationMessageDateFormatting.kt create mode 100644 src/com/android/messaging/ui/conversation/v2/messages/ui/text/ConversationMessageText.kt create mode 100644 src/com/android/messaging/ui/conversation/v2/messages/ui/text/ConversationMessageTextLinkExtractor.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 88106ca8..eb5d971b 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -148,6 +148,7 @@ dependencies { implementation(libs.androidx.lifecycle.viewmodel.ktx) implementation(libs.coil.compose) + implementation(libs.coil.network.okhttp) implementation(libs.glide) implementation(libs.hilt.android) diff --git a/config/detekt/detekt.yml b/config/detekt/detekt.yml index a0631a34..0aff4317 100644 --- a/config/detekt/detekt.yml +++ b/config/detekt/detekt.yml @@ -18,6 +18,8 @@ style: MagicNumber: ignoreCompanionObjectPropertyDeclaration: true ignorePropertyDeclaration: true + ignoreAnnotated: + - Composable UnusedPrivateFunction: ignoreAnnotated: - Preview diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a2ddb6a0..64c06b20 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -66,6 +66,7 @@ androidx-preference = { module = "androidx.preference:preference", version.ref = androidx-recyclerview = { module = "androidx.recyclerview:recyclerview", version.ref = "recyclerview" } coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" } +coil-network-okhttp = { module = "io.coil-kt.coil3:coil-network-okhttp", version.ref = "coil" } glide = { module = "com.github.bumptech.glide:glide", version.ref = "glide" } guava = { module = "com.google.guava:guava", version.ref = "guava" } diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml index c588388a..0e21afda 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -7750,5 +7750,84 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/com/android/messaging/ui/conversation/v2/ConversationTestTags.kt b/src/com/android/messaging/ui/conversation/v2/ConversationTestTags.kt index 8621debd..d0f2edbe 100644 --- a/src/com/android/messaging/ui/conversation/v2/ConversationTestTags.kt +++ b/src/com/android/messaging/ui/conversation/v2/ConversationTestTags.kt @@ -4,8 +4,12 @@ import androidx.compose.ui.semantics.SemanticsPropertyKey import androidx.compose.ui.semantics.SemanticsPropertyReceiver internal const val CONVERSATION_COMPOSE_BAR_TEST_TAG = "conversation_compose_bar" +internal const val CONVERSATION_ATTACHMENT_BUTTON_TEST_TAG = "conversation_attachment_button" +internal const val CONVERSATION_ATTACHMENT_PREVIEW_LIST_TEST_TAG = + "conversation_attachment_preview_list" internal const val CONVERSATION_LOADING_INDICATOR_TEST_TAG = "conversation_loading_indicator" internal const val CONVERSATION_MESSAGES_LIST_TEST_TAG = "conversation_messages_list" +internal const val CONVERSATION_MEDIA_PICKER_OVERLAY_TEST_TAG = "conversation_media_picker_overlay" internal const val CONVERSATION_SEND_BUTTON_SHAPE_CIRCLE = "circle" internal const val CONVERSATION_SEND_BUTTON_TEST_TAG = "conversation_send_button" internal const val CONVERSATION_TEXT_FIELD_TEST_TAG = "conversation_text_field" @@ -14,6 +18,16 @@ internal fun conversationMessageItemTestTag(messageId: String): String { return "conversation_message_item_$messageId" } +internal fun conversationAttachmentPreviewItemTestTag(attachmentKey: String): String { + return "conversation_attachment_preview_item_$attachmentKey" +} + +internal fun conversationAttachmentPreviewRemoveButtonTestTag( + attachmentKey: String, +): String { + return "conversation_attachment_preview_remove_button_$attachmentKey" +} + internal val conversationShapeSemanticsKey = SemanticsPropertyKey( name = "conversation_shape", ) diff --git a/src/com/android/messaging/ui/conversation/v2/composer/mapper/ConversationComposerUiStateMapper.kt b/src/com/android/messaging/ui/conversation/v2/composer/mapper/ConversationComposerUiStateMapper.kt index c4beb3d4..ee94d556 100644 --- a/src/com/android/messaging/ui/conversation/v2/composer/mapper/ConversationComposerUiStateMapper.kt +++ b/src/com/android/messaging/ui/conversation/v2/composer/mapper/ConversationComposerUiStateMapper.kt @@ -4,9 +4,9 @@ import com.android.messaging.data.conversation.model.metadata.ConversationCompos import com.android.messaging.ui.conversation.v2.composer.model.ConversationComposerAttachmentUiState import com.android.messaging.ui.conversation.v2.composer.model.ConversationComposerUiState import com.android.messaging.ui.conversation.v2.composer.model.ConversationDraftState +import javax.inject.Inject import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList -import javax.inject.Inject internal interface ConversationComposerUiStateMapper { fun map( diff --git a/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationAttachmentPreview.kt b/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationAttachmentPreview.kt index 85d76002..8960bbc7 100644 --- a/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationAttachmentPreview.kt +++ b/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationAttachmentPreview.kt @@ -26,11 +26,15 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import com.android.messaging.R +import com.android.messaging.ui.conversation.v2.CONVERSATION_ATTACHMENT_PREVIEW_LIST_TEST_TAG import com.android.messaging.ui.conversation.v2.composer.model.ConversationComposerAttachmentUiState +import com.android.messaging.ui.conversation.v2.conversationAttachmentPreviewItemTestTag +import com.android.messaging.ui.conversation.v2.conversationAttachmentPreviewRemoveButtonTestTag import com.android.messaging.ui.conversation.v2.mediapicker.component.ConversationMediaThumbnail import com.android.messaging.util.ContentType @@ -50,7 +54,7 @@ internal fun ConversationAttachmentPreview( } LazyRow( - modifier = modifier, + modifier = modifier.testTag(CONVERSATION_ATTACHMENT_PREVIEW_LIST_TEST_TAG), contentPadding = PaddingValues( start = 12.dp, top = 4.dp, @@ -66,6 +70,7 @@ internal fun ConversationAttachmentPreview( when (attachment) { is ConversationComposerAttachmentUiState.Pending -> { PendingAttachmentPreviewItem( + attachmentKey = attachment.key, onRemoveClick = { onPendingAttachmentRemove(attachment.key) }, @@ -75,6 +80,7 @@ internal fun ConversationAttachmentPreview( is ConversationComposerAttachmentUiState.Resolved -> { ResolvedAttachmentPreviewItem( attachment = attachment, + attachmentKey = attachment.key, onAttachmentClick = { onResolvedAttachmentClick(attachment) }, @@ -90,9 +96,11 @@ internal fun ConversationAttachmentPreview( @Composable private fun PendingAttachmentPreviewItem( + attachmentKey: String, onRemoveClick: () -> Unit, ) { AttachmentPreviewItemContainer( + attachmentKey = attachmentKey, onClick = {}, ) { Box( @@ -113,13 +121,17 @@ private fun PendingAttachmentPreviewItem( ) } - RemoveAttachmentButton(onClick = onRemoveClick) + RemoveAttachmentButton( + attachmentKey = attachmentKey, + onClick = onRemoveClick, + ) } } @Composable private fun ResolvedAttachmentPreviewItem( attachment: ConversationComposerAttachmentUiState.Resolved, + attachmentKey: String, onAttachmentClick: () -> Unit, onRemoveClick: () -> Unit, ) { @@ -129,6 +141,7 @@ private fun ResolvedAttachmentPreviewItem( ) AttachmentPreviewItemContainer( + attachmentKey = attachmentKey, onClick = onAttachmentClick, ) { ConversationMediaThumbnail( @@ -152,12 +165,16 @@ private fun ResolvedAttachmentPreviewItem( } } - RemoveAttachmentButton(onClick = onRemoveClick) + RemoveAttachmentButton( + attachmentKey = attachmentKey, + onClick = onRemoveClick, + ) } } @Composable private fun AttachmentPreviewItemContainer( + attachmentKey: String, onClick: () -> Unit, content: @Composable BoxScope.() -> Unit, ) { @@ -165,7 +182,12 @@ private fun AttachmentPreviewItemContainer( modifier = Modifier .size(88.dp) .clip(RoundedCornerShape(ATTACHMENT_PREVIEW_CORNER_RADIUS)) - .clickable(onClick = onClick), + .clickable(onClick = onClick) + .testTag( + conversationAttachmentPreviewItemTestTag( + attachmentKey = attachmentKey, + ), + ), shape = RoundedCornerShape(ATTACHMENT_PREVIEW_CORNER_RADIUS), color = MaterialTheme.colorScheme.surfaceContainerHigh, ) { @@ -174,12 +196,20 @@ private fun AttachmentPreviewItemContainer( } @Composable -private fun BoxScope.RemoveAttachmentButton(onClick: () -> Unit) { +private fun BoxScope.RemoveAttachmentButton( + attachmentKey: String, + onClick: () -> Unit, +) { FilledIconButton( modifier = Modifier .align(Alignment.TopEnd) .padding(6.dp) - .size(28.dp), + .size(28.dp) + .testTag( + conversationAttachmentPreviewRemoveButtonTestTag( + attachmentKey = attachmentKey, + ), + ), onClick = onClick, colors = IconButtonDefaults.filledIconButtonColors( containerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.9f), diff --git a/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationComposeBar.kt b/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationComposeBar.kt index 347cfddc..6df454c2 100644 --- a/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationComposeBar.kt +++ b/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationComposeBar.kt @@ -33,6 +33,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp import com.android.messaging.R +import com.android.messaging.ui.conversation.v2.CONVERSATION_ATTACHMENT_BUTTON_TEST_TAG import com.android.messaging.ui.conversation.v2.CONVERSATION_COMPOSE_BAR_TEST_TAG import com.android.messaging.ui.conversation.v2.CONVERSATION_SEND_BUTTON_SHAPE_CIRCLE import com.android.messaging.ui.conversation.v2.CONVERSATION_SEND_BUTTON_TEST_TAG @@ -156,6 +157,7 @@ private fun ConversationComposeTextField( }, trailingIcon = { ConversationComposeImageAction( + modifier = Modifier.testTag(CONVERSATION_ATTACHMENT_BUTTON_TEST_TAG), enabled = isAttachmentActionEnabled, onClick = onAttachmentClick, ) @@ -186,12 +188,14 @@ private fun ConversationComposePlaceholder() { @Composable private fun ConversationComposeImageAction( + modifier: Modifier = Modifier, enabled: Boolean, onClick: () -> Unit, ) { val hapticFeedback = LocalHapticFeedback.current IconButton( + modifier = modifier, onClick = { hapticFeedback.performHapticFeedback(HapticFeedbackType.ContextClick) onClick() diff --git a/src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationMediaPickerOverlay.kt b/src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationMediaPickerOverlay.kt index 6414a501..faf7dd25 100644 --- a/src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationMediaPickerOverlay.kt +++ b/src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationMediaPickerOverlay.kt @@ -14,7 +14,9 @@ import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.platform.testTag import com.android.messaging.data.media.model.ConversationMediaItem +import com.android.messaging.ui.conversation.v2.CONVERSATION_MEDIA_PICKER_OVERLAY_TEST_TAG import com.android.messaging.ui.conversation.v2.composer.model.ConversationComposerAttachmentUiState import com.android.messaging.ui.conversation.v2.mediapicker.model.ConversationCapturedMedia import com.android.messaging.ui.conversation.v2.mediapicker.model.ConversationMediaPickerUiState @@ -43,9 +45,7 @@ internal fun ConversationMediaPickerOverlay( val isImeVisible = WindowInsets.isImeVisible val keyboardController = LocalSoftwareKeyboardController.current - val permissionState = rememberConversationMediaPickerPermissionState( - context = context, - ) + val permissionState = rememberConversationMediaPickerPermissionState(context = context) val audioPermissionLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.RequestPermission(), @@ -95,7 +95,9 @@ internal fun ConversationMediaPickerOverlay( } ConversationMediaPicker( - modifier = modifier.fillMaxSize(), + modifier = modifier + .fillMaxSize() + .testTag(CONVERSATION_MEDIA_PICKER_OVERLAY_TEST_TAG), uiState = mediaPickerUiState, attachments = attachments, conversationTitle = conversationTitle, diff --git a/src/com/android/messaging/ui/conversation/v2/messages/delegate/ConversationMessagesDelegate.kt b/src/com/android/messaging/ui/conversation/v2/messages/delegate/ConversationMessagesDelegate.kt index d512e320..3785c12e 100644 --- a/src/com/android/messaging/ui/conversation/v2/messages/delegate/ConversationMessagesDelegate.kt +++ b/src/com/android/messaging/ui/conversation/v2/messages/delegate/ConversationMessagesDelegate.kt @@ -4,7 +4,8 @@ import com.android.messaging.data.conversation.repository.ConversationsRepositor import com.android.messaging.di.core.DefaultDispatcher import com.android.messaging.ui.conversation.v2.common.ConversationScreenDelegate import com.android.messaging.ui.conversation.v2.messages.mapper.ConversationMessageUiModelMapper -import com.android.messaging.ui.conversation.v2.messages.model.ConversationMessagesUiState +import com.android.messaging.ui.conversation.v2.messages.model.message.ConversationMessagesUiState +import javax.inject.Inject import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope @@ -15,7 +16,6 @@ import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch -import javax.inject.Inject internal interface ConversationMessagesDelegate : ConversationScreenDelegate diff --git a/src/com/android/messaging/ui/conversation/v2/messages/mapper/ConversationMessageUiModelMapper.kt b/src/com/android/messaging/ui/conversation/v2/messages/mapper/ConversationMessageUiModelMapper.kt index 27f1f8e4..69109761 100644 --- a/src/com/android/messaging/ui/conversation/v2/messages/mapper/ConversationMessageUiModelMapper.kt +++ b/src/com/android/messaging/ui/conversation/v2/messages/mapper/ConversationMessageUiModelMapper.kt @@ -3,9 +3,9 @@ package com.android.messaging.ui.conversation.v2.messages.mapper import com.android.messaging.datamodel.data.ConversationMessageData import com.android.messaging.datamodel.data.MessageData import com.android.messaging.datamodel.data.MessagePartData -import com.android.messaging.ui.conversation.v2.messages.model.ConversationMessagePartUiModel -import com.android.messaging.ui.conversation.v2.messages.model.ConversationMessageUiModel -import com.android.messaging.ui.conversation.v2.messages.model.ConversationMessageUiModel.Status +import com.android.messaging.ui.conversation.v2.messages.model.message.ConversationMessagePartUiModel +import com.android.messaging.ui.conversation.v2.messages.model.message.ConversationMessageUiModel +import com.android.messaging.ui.conversation.v2.messages.model.message.ConversationMessageUiModel.Status import com.android.messaging.util.LogUtil import javax.inject.Inject @@ -112,11 +112,8 @@ internal class ConversationMessageUiModelMapperImpl @Inject constructor() : else -> sentTimestamp } - if (primaryTimestamp > 0L) { - return primaryTimestamp - } - return when { + primaryTimestamp > 0L -> primaryTimestamp isIncoming -> sentTimestamp else -> receivedTimestamp } diff --git a/src/com/android/messaging/ui/conversation/v2/messages/model/ConversationMessagePartUiModel.kt b/src/com/android/messaging/ui/conversation/v2/messages/model/ConversationMessagePartUiModel.kt deleted file mode 100644 index e327cbd8..00000000 --- a/src/com/android/messaging/ui/conversation/v2/messages/model/ConversationMessagePartUiModel.kt +++ /dev/null @@ -1,13 +0,0 @@ -package com.android.messaging.ui.conversation.v2.messages.model - -import android.net.Uri -import androidx.compose.runtime.Immutable - -@Immutable -internal data class ConversationMessagePartUiModel( - val contentType: String, - val text: String?, - val contentUri: Uri?, - val width: Int, - val height: Int, -) diff --git a/src/com/android/messaging/ui/conversation/v2/messages/model/attachment/ConversationAttachmentOpenAction.kt b/src/com/android/messaging/ui/conversation/v2/messages/model/attachment/ConversationAttachmentOpenAction.kt new file mode 100644 index 00000000..fbbcebdc --- /dev/null +++ b/src/com/android/messaging/ui/conversation/v2/messages/model/attachment/ConversationAttachmentOpenAction.kt @@ -0,0 +1,18 @@ +package com.android.messaging.ui.conversation.v2.messages.model.attachment + +import androidx.compose.runtime.Immutable + +@Immutable +internal sealed interface ConversationAttachmentOpenAction { + + @Immutable + data class OpenContent( + val contentType: String, + val contentUri: String, + ) : ConversationAttachmentOpenAction + + @Immutable + data class OpenExternal( + val uri: String, + ) : ConversationAttachmentOpenAction +} diff --git a/src/com/android/messaging/ui/conversation/v2/messages/model/attachment/ConversationAttachmentSections.kt b/src/com/android/messaging/ui/conversation/v2/messages/model/attachment/ConversationAttachmentSections.kt new file mode 100644 index 00000000..56669896 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/v2/messages/model/attachment/ConversationAttachmentSections.kt @@ -0,0 +1,27 @@ +package com.android.messaging.ui.conversation.v2.messages.model.attachment + +import androidx.compose.runtime.Immutable +import kotlinx.collections.immutable.ImmutableList + +@Immutable +internal data class ConversationAttachmentSections( + val galleryVisualAttachments: ImmutableList, + val trailingItems: ImmutableList, +) + +@Immutable +internal sealed interface ConversationAttachmentItem { + val key: String + + @Immutable + data class StandaloneVisual( + override val key: String, + val attachment: ConversationMessageAttachment, + ) : ConversationAttachmentItem + + @Immutable + data class Inline( + override val key: String, + val attachment: ConversationInlineAttachment, + ) : ConversationAttachmentItem +} diff --git a/src/com/android/messaging/ui/conversation/v2/messages/model/attachment/ConversationInlineAttachment.kt b/src/com/android/messaging/ui/conversation/v2/messages/model/attachment/ConversationInlineAttachment.kt new file mode 100644 index 00000000..19118011 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/v2/messages/model/attachment/ConversationInlineAttachment.kt @@ -0,0 +1,20 @@ +package com.android.messaging.ui.conversation.v2.messages.model.attachment + +import androidx.compose.runtime.Immutable + +@Immutable +internal data class ConversationInlineAttachment( + val key: String, + val kind: ConversationInlineAttachmentKind, + val openAction: ConversationAttachmentOpenAction?, + val subtitleTextResId: Int?, + val titleText: String?, + val titleTextResId: Int?, +) + +@Immutable +internal enum class ConversationInlineAttachmentKind { + AUDIO, + FILE, + VCARD, +} diff --git a/src/com/android/messaging/ui/conversation/v2/messages/model/attachment/ConversationMessageAttachment.kt b/src/com/android/messaging/ui/conversation/v2/messages/model/attachment/ConversationMessageAttachment.kt new file mode 100644 index 00000000..0ca36cca --- /dev/null +++ b/src/com/android/messaging/ui/conversation/v2/messages/model/attachment/ConversationMessageAttachment.kt @@ -0,0 +1,28 @@ +package com.android.messaging.ui.conversation.v2.messages.model.attachment + +import androidx.compose.runtime.Immutable +import com.android.messaging.ui.conversation.v2.messages.model.message.ConversationMessagePartUiModel + +@Immutable +internal sealed interface ConversationMessageAttachment { + val key: String + + @Immutable + data class Media( + override val key: String, + val part: ConversationMessagePartUiModel, + ) : ConversationMessageAttachment + + @Immutable + data class Unsupported( + override val key: String, + val part: ConversationMessagePartUiModel, + ) : ConversationMessageAttachment + + @Immutable + data class YouTubePreview( + override val key: String, + val sourceUrl: String, + val thumbnailUrl: String, + ) : ConversationMessageAttachment +} diff --git a/src/com/android/messaging/ui/conversation/v2/messages/model/message/ConversationMessageContent.kt b/src/com/android/messaging/ui/conversation/v2/messages/model/message/ConversationMessageContent.kt new file mode 100644 index 00000000..67b928fe --- /dev/null +++ b/src/com/android/messaging/ui/conversation/v2/messages/model/message/ConversationMessageContent.kt @@ -0,0 +1,15 @@ +package com.android.messaging.ui.conversation.v2.messages.model.message + +import androidx.compose.runtime.Immutable +import com.android.messaging.ui.conversation.v2.messages.model.attachment.ConversationAttachmentSections +import com.android.messaging.ui.conversation.v2.messages.model.attachment.ConversationMessageAttachment +import kotlinx.collections.immutable.ImmutableList + +@Immutable +internal data class ConversationMessageContent( + val subjectText: String?, + val bodyText: String?, + val attachments: ImmutableList, + val attachmentSections: ConversationAttachmentSections, + val isAttachmentOnly: Boolean, +) diff --git a/src/com/android/messaging/ui/conversation/v2/messages/model/message/ConversationMessagePartUiModel.kt b/src/com/android/messaging/ui/conversation/v2/messages/model/message/ConversationMessagePartUiModel.kt new file mode 100644 index 00000000..10cbd800 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/v2/messages/model/message/ConversationMessagePartUiModel.kt @@ -0,0 +1,53 @@ +package com.android.messaging.ui.conversation.v2.messages.model.message + +import android.net.Uri +import androidx.compose.runtime.Immutable +import com.android.messaging.util.ContentType + +@Immutable +internal data class ConversationMessagePartUiModel( + val contentType: String, + val text: String?, + val contentUri: Uri?, + val width: Int, + val height: Int, +) { + val hasCaptionText: Boolean by lazy(mode = LazyThreadSafetyMode.NONE) { + !text.isNullOrBlank() + } + + val hasRenderableContentUri: Boolean by lazy(mode = LazyThreadSafetyMode.NONE) { + contentUri != null + } + + val isAudioAttachment: Boolean by lazy(mode = LazyThreadSafetyMode.NONE) { + ContentType.isAudioType(contentType) + } + + val isImageAttachment: Boolean by lazy(mode = LazyThreadSafetyMode.NONE) { + ContentType.isImageType(contentType) + } + + val isMediaAttachment: Boolean by lazy(mode = LazyThreadSafetyMode.NONE) { + ContentType.isMediaType(contentType) + } + + val isSupportedAttachment: Boolean by lazy(mode = LazyThreadSafetyMode.NONE) { + isImageAttachment || + isVideoAttachment || + isAudioAttachment || + isVCardAttachment + } + + val isTextPart: Boolean by lazy(mode = LazyThreadSafetyMode.NONE) { + ContentType.isTextType(contentType) + } + + val isVCardAttachment: Boolean by lazy(mode = LazyThreadSafetyMode.NONE) { + ContentType.isVCardType(contentType) + } + + val isVideoAttachment: Boolean by lazy(mode = LazyThreadSafetyMode.NONE) { + ContentType.isVideoType(contentType) + } +} diff --git a/src/com/android/messaging/ui/conversation/v2/messages/model/ConversationMessageUiModel.kt b/src/com/android/messaging/ui/conversation/v2/messages/model/message/ConversationMessageUiModel.kt similarity index 96% rename from src/com/android/messaging/ui/conversation/v2/messages/model/ConversationMessageUiModel.kt rename to src/com/android/messaging/ui/conversation/v2/messages/model/message/ConversationMessageUiModel.kt index 7b2dbbf5..240f7a8d 100644 --- a/src/com/android/messaging/ui/conversation/v2/messages/model/ConversationMessageUiModel.kt +++ b/src/com/android/messaging/ui/conversation/v2/messages/model/message/ConversationMessageUiModel.kt @@ -1,9 +1,8 @@ -package com.android.messaging.ui.conversation.v2.messages.model +package com.android.messaging.ui.conversation.v2.messages.model.message import android.net.Uri import androidx.compose.runtime.Immutable import androidx.compose.runtime.Stable - @Immutable internal data class ConversationMessageUiModel( val messageId: String, diff --git a/src/com/android/messaging/ui/conversation/v2/messages/model/ConversationMessagesUiState.kt b/src/com/android/messaging/ui/conversation/v2/messages/model/message/ConversationMessagesUiState.kt similarity index 86% rename from src/com/android/messaging/ui/conversation/v2/messages/model/ConversationMessagesUiState.kt rename to src/com/android/messaging/ui/conversation/v2/messages/model/message/ConversationMessagesUiState.kt index 2984638c..3840b748 100644 --- a/src/com/android/messaging/ui/conversation/v2/messages/model/ConversationMessagesUiState.kt +++ b/src/com/android/messaging/ui/conversation/v2/messages/model/message/ConversationMessagesUiState.kt @@ -1,4 +1,4 @@ -package com.android.messaging.ui.conversation.v2.messages.model +package com.android.messaging.ui.conversation.v2.messages.model.message import androidx.compose.runtime.Immutable import kotlinx.collections.immutable.ImmutableList diff --git a/src/com/android/messaging/ui/conversation/v2/messages/ui/ConversationMessageDisplay.kt b/src/com/android/messaging/ui/conversation/v2/messages/ui/ConversationMessageDisplay.kt deleted file mode 100644 index 3e249d3f..00000000 --- a/src/com/android/messaging/ui/conversation/v2/messages/ui/ConversationMessageDisplay.kt +++ /dev/null @@ -1,34 +0,0 @@ -package com.android.messaging.ui.conversation.v2.messages.ui - -import java.time.Instant -import java.time.LocalDate -import java.time.ZoneId -import java.util.TimeZone - -private const val MILLIS_PER_DAY = 86_400_000L - -internal fun conversationMessageDisplayEpochDay( - displayTimestamp: Long, - timeZone: TimeZone, -): Long? { - if (displayTimestamp <= 0L) { - return null - } - - val localTimestamp = displayTimestamp + timeZone.getOffset(displayTimestamp) - - return Math.floorDiv(localTimestamp, MILLIS_PER_DAY) -} - -internal fun conversationMessageDisplayLocalDate( - displayTimestamp: Long, -): LocalDate? { - if (displayTimestamp <= 0L) { - return null - } - - return Instant - .ofEpochMilli(displayTimestamp) - .atZone(ZoneId.systemDefault()) - .toLocalDate() -} diff --git a/src/com/android/messaging/ui/conversation/v2/messages/ui/ConversationMessages.kt b/src/com/android/messaging/ui/conversation/v2/messages/ui/ConversationMessages.kt index a7eace07..62ca9273 100644 --- a/src/com/android/messaging/ui/conversation/v2/messages/ui/ConversationMessages.kt +++ b/src/com/android/messaging/ui/conversation/v2/messages/ui/ConversationMessages.kt @@ -1,7 +1,5 @@ package com.android.messaging.ui.conversation.v2.messages.ui -import android.content.Context -import android.text.format.DateUtils import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -26,13 +24,12 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.android.messaging.ui.conversation.v2.CONVERSATION_MESSAGES_LIST_TEST_TAG import com.android.messaging.ui.conversation.v2.conversationMessageItemTestTag -import com.android.messaging.ui.conversation.v2.messages.model.ConversationMessageUiModel -import java.time.LocalDate +import com.android.messaging.ui.conversation.v2.messages.model.message.ConversationMessageUiModel +import com.android.messaging.ui.conversation.v2.messages.ui.message.ConversationMessage +import com.android.messaging.ui.conversation.v2.messages.ui.message.conversationMessageDisplayEpochDay +import com.android.messaging.ui.conversation.v2.messages.ui.message.formatDateSeparatorText import java.util.TimeZone - -private const val COMMON_DATE_SEPARATOR_FORMAT_FLAGS = DateUtils.FORMAT_SHOW_WEEKDAY or - DateUtils.FORMAT_SHOW_DATE or - DateUtils.FORMAT_ABBREV_MONTH +import kotlinx.collections.immutable.ImmutableList private val CONVERSATION_MESSAGES_CONTENT_PADDING = PaddingValues( start = 16.dp, @@ -57,8 +54,10 @@ private enum class ConversationMessagesItemContentType { @Composable internal fun ConversationMessages( modifier: Modifier = Modifier, - messages: List, + messages: ImmutableList, listState: LazyListState, + onAttachmentClick: (contentType: String, contentUri: String) -> Unit, + onExternalUriClick: (String) -> Unit, ) { val configuration = LocalConfiguration.current val displayMessages = remember(messages) { @@ -94,6 +93,8 @@ internal fun ConversationMessages( messages = displayMessages, index = index, ), + onAttachmentClick = onAttachmentClick, + onExternalUriClick = onExternalUriClick, ) } } @@ -137,6 +138,8 @@ private fun messageAboveCurrent( private fun ConversationMessagesItem( message: ConversationMessageUiModel, messageAbove: ConversationMessageUiModel?, + onAttachmentClick: (contentType: String, contentUri: String) -> Unit, + onExternalUriClick: (String) -> Unit, ) { val presentation = rememberConversationMessagesItemPresentation( message = message, @@ -152,6 +155,8 @@ private fun ConversationMessagesItem( .testTag(conversationMessageItemTestTag(messageId = message.messageId)) .padding(top = presentation.topPadding), message = message, + onAttachmentClick = onAttachmentClick, + onExternalUriClick = onExternalUriClick, ) } } @@ -286,6 +291,7 @@ private fun shouldShowDateSeparator( displayTimestamp = currentMessage.displayTimestamp, timeZone = timeZone, ) ?: return false + val messageAboveEpochDay = conversationMessageDisplayEpochDay( displayTimestamp = messageAbove.displayTimestamp, timeZone = timeZone, @@ -293,29 +299,3 @@ private fun shouldShowDateSeparator( return messageAboveEpochDay != currentEpochDay } - -private fun formatDateSeparatorText( - context: Context, - message: ConversationMessageUiModel, -): String? { - val timestamp = message.displayTimestamp - - if (timestamp <= 0L) { - return null - } - - val isSameYear = conversationMessageDisplayLocalDate( - displayTimestamp = timestamp, - )?.year == LocalDate.now().year - - val dateTimeFormatFlags = when { - isSameYear -> COMMON_DATE_SEPARATOR_FORMAT_FLAGS or DateUtils.FORMAT_NO_YEAR - else -> COMMON_DATE_SEPARATOR_FORMAT_FLAGS or DateUtils.FORMAT_SHOW_YEAR - } - - return DateUtils.formatDateTime( - context, - timestamp, - dateTimeFormatFlags, - ) -} diff --git a/src/com/android/messaging/ui/conversation/v2/messages/ui/attachment/ConversationAttachmentActionDispatcher.kt b/src/com/android/messaging/ui/conversation/v2/messages/ui/attachment/ConversationAttachmentActionDispatcher.kt new file mode 100644 index 00000000..7301ca49 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/v2/messages/ui/attachment/ConversationAttachmentActionDispatcher.kt @@ -0,0 +1,50 @@ +package com.android.messaging.ui.conversation.v2.messages.ui.attachment + +import com.android.messaging.ui.conversation.v2.messages.model.attachment.ConversationAttachmentOpenAction +import com.android.messaging.ui.conversation.v2.messages.model.attachment.ConversationMessageAttachment + +internal fun dispatchConversationAttachmentOpenAction( + action: ConversationAttachmentOpenAction, + onAttachmentClick: (contentType: String, contentUri: String) -> Unit, + onExternalUriClick: (String) -> Unit, +) { + when (action) { + is ConversationAttachmentOpenAction.OpenContent -> { + onAttachmentClick( + action.contentType, + action.contentUri, + ) + } + + is ConversationAttachmentOpenAction.OpenExternal -> { + onExternalUriClick(action.uri) + } + } +} + +internal fun ConversationMessageAttachment.toConversationAttachmentOpenActionOrNull(): + ConversationAttachmentOpenAction? { + return when (this) { + is ConversationMessageAttachment.Media -> { + ConversationAttachmentOpenAction.OpenContent( + contentType = part.contentType, + contentUri = part.contentUri.toString(), + ) + } + + is ConversationMessageAttachment.Unsupported -> { + part.contentUri?.let { contentUri -> + ConversationAttachmentOpenAction.OpenContent( + contentType = part.contentType, + contentUri = contentUri.toString(), + ) + } + } + + is ConversationMessageAttachment.YouTubePreview -> { + ConversationAttachmentOpenAction.OpenExternal( + uri = sourceUrl, + ) + } + } +} diff --git a/src/com/android/messaging/ui/conversation/v2/messages/ui/attachment/ConversationAttachmentSectionsBuilder.kt b/src/com/android/messaging/ui/conversation/v2/messages/ui/attachment/ConversationAttachmentSectionsBuilder.kt new file mode 100644 index 00000000..812a4b61 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/v2/messages/ui/attachment/ConversationAttachmentSectionsBuilder.kt @@ -0,0 +1,185 @@ +package com.android.messaging.ui.conversation.v2.messages.ui.attachment + +import com.android.messaging.R +import com.android.messaging.ui.conversation.v2.messages.model.attachment.ConversationAttachmentItem +import com.android.messaging.ui.conversation.v2.messages.model.attachment.ConversationAttachmentOpenAction +import com.android.messaging.ui.conversation.v2.messages.model.attachment.ConversationAttachmentSections +import com.android.messaging.ui.conversation.v2.messages.model.attachment.ConversationInlineAttachment +import com.android.messaging.ui.conversation.v2.messages.model.attachment.ConversationInlineAttachmentKind +import com.android.messaging.ui.conversation.v2.messages.model.attachment.ConversationMessageAttachment +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList + +internal fun buildConversationAttachmentSections( + attachments: ImmutableList, +): ConversationAttachmentSections { + val galleryVisualAttachments = attachments + .asSequence() + .filter(::isGalleryVisualAttachment) + .toImmutableList() + + val trailingItems = attachments + .asSequence() + .filterNot(::isGalleryVisualAttachment) + .mapNotNull(::toConversationAttachmentItem) + .toImmutableList() + + return ConversationAttachmentSections( + galleryVisualAttachments = galleryVisualAttachments, + trailingItems = trailingItems, + ) +} + +private fun isGalleryVisualAttachment( + attachment: ConversationMessageAttachment, +): Boolean { + return when (attachment) { + is ConversationMessageAttachment.Media -> attachment.part.isImageAttachment + is ConversationMessageAttachment.YouTubePreview -> true + is ConversationMessageAttachment.Unsupported -> false + } +} + +private fun isStandaloneVisualAttachment( + attachment: ConversationMessageAttachment, +): Boolean { + return when (attachment) { + is ConversationMessageAttachment.Media -> attachment.part.isVideoAttachment + + is ConversationMessageAttachment.Unsupported, + is ConversationMessageAttachment.YouTubePreview, + -> false + } +} + +private fun toConversationAttachmentItem( + attachment: ConversationMessageAttachment, +): ConversationAttachmentItem? { + return when { + isStandaloneVisualAttachment(attachment = attachment) -> { + ConversationAttachmentItem.StandaloneVisual( + key = attachment.key, + attachment = attachment, + ) + } + + isInlineAttachment(attachment = attachment) -> { + toInlineAttachment(attachment = attachment) + ?.let { inlineAttachment -> + ConversationAttachmentItem.Inline( + key = inlineAttachment.key, + attachment = inlineAttachment, + ) + } + } + + else -> null + } +} + +private fun isInlineAttachment( + attachment: ConversationMessageAttachment, +): Boolean { + return when (attachment) { + is ConversationMessageAttachment.Media, + is ConversationMessageAttachment.Unsupported, + -> true + + else -> false + } +} + +private fun toInlineAttachment( + attachment: ConversationMessageAttachment, +): ConversationInlineAttachment? { + return when (attachment) { + is ConversationMessageAttachment.Media -> { + toMediaInlineAttachment( + attachment = attachment, + ) + } + + is ConversationMessageAttachment.Unsupported -> { + createFileInlineAttachment( + key = attachment.key, + titleText = attachment.part.contentType.ifBlank { null }, + openAction = attachment.toConversationAttachmentOpenActionOrNull(), + ) + } + + is ConversationMessageAttachment.YouTubePreview -> null + } +} + +private fun toMediaInlineAttachment( + attachment: ConversationMessageAttachment.Media, +): ConversationInlineAttachment? { + return when { + attachment.part.isAudioAttachment -> { + createAudioInlineAttachment( + key = attachment.key, + openAction = attachment.toConversationAttachmentOpenActionOrNull(), + ) + } + + attachment.part.isVCardAttachment -> { + createVCardInlineAttachment( + key = attachment.key, + openAction = attachment.toConversationAttachmentOpenActionOrNull(), + ) + } + + attachment.part.isImageAttachment || attachment.part.isVideoAttachment -> null + + else -> { + createFileInlineAttachment( + key = attachment.key, + titleText = attachment.part.contentType.ifBlank { null }, + openAction = attachment.toConversationAttachmentOpenActionOrNull(), + ) + } + } +} + +private fun createAudioInlineAttachment( + key: String, + openAction: ConversationAttachmentOpenAction?, +): ConversationInlineAttachment { + return ConversationInlineAttachment( + key = key, + kind = ConversationInlineAttachmentKind.AUDIO, + openAction = openAction, + subtitleTextResId = null, + titleText = null, + titleTextResId = R.string.audio_attachment_content_description, + ) +} + +private fun createVCardInlineAttachment( + key: String, + openAction: ConversationAttachmentOpenAction?, +): ConversationInlineAttachment { + return ConversationInlineAttachment( + key = key, + kind = ConversationInlineAttachmentKind.VCARD, + openAction = openAction, + subtitleTextResId = R.string.vcard_tap_hint, + titleText = null, + titleTextResId = R.string.notification_vcard, + ) +} + +private fun createFileInlineAttachment( + key: String, + titleText: String?, + openAction: ConversationAttachmentOpenAction?, +): ConversationInlineAttachment { + return ConversationInlineAttachment( + key = key, + kind = ConversationInlineAttachmentKind.FILE, + openAction = openAction, + subtitleTextResId = null, + titleText = titleText, + titleTextResId = R.string.notification_file, + ) +} diff --git a/src/com/android/messaging/ui/conversation/v2/messages/ui/attachment/ConversationInlineAttachmentRow.kt b/src/com/android/messaging/ui/conversation/v2/messages/ui/attachment/ConversationInlineAttachmentRow.kt new file mode 100644 index 00000000..5b6d8ea4 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/v2/messages/ui/attachment/ConversationInlineAttachmentRow.kt @@ -0,0 +1,131 @@ +package com.android.messaging.ui.conversation.v2.messages.ui.attachment + +import androidx.compose.foundation.clickable +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 +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Description +import androidx.compose.material.icons.rounded.Person +import androidx.compose.material.icons.rounded.PlayArrow +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.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.android.messaging.R +import com.android.messaging.ui.conversation.v2.messages.model.attachment.ConversationInlineAttachment +import com.android.messaging.ui.conversation.v2.messages.model.attachment.ConversationInlineAttachmentKind + +@Composable +internal fun ConversationInlineAttachmentRow( + attachment: ConversationInlineAttachment, + onAttachmentClick: (contentType: String, contentUri: String) -> Unit, + onExternalUriClick: (String) -> Unit, +) { + val title = attachment.titleText + ?: attachment.titleTextResId?.let { stringResource(it) }.orEmpty() + + val subtitle = attachment.subtitleTextResId?.let { stringResource(it) } + + val onClick = attachment.openAction?.let { action -> + { + dispatchConversationAttachmentOpenAction( + action = action, + onAttachmentClick = onAttachmentClick, + onExternalUriClick = onExternalUriClick, + ) + } + } + + val shape = RoundedCornerShape(size = MESSAGE_ATTACHMENT_CORNER_RADIUS) + + Surface( + modifier = Modifier + .fillMaxWidth() + .clip(shape = shape) + .clickable( + enabled = onClick != null, + onClick = { + onClick?.invoke() + }, + ), + color = MaterialTheme.colorScheme.surfaceContainerHighest, + shape = shape, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 14.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.spacedBy(space = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier.size(size = 28.dp), + contentAlignment = Alignment.Center, + ) { + ConversationInlineAttachmentIcon( + kind = attachment.kind, + ) + } + + Column( + verticalArrangement = Arrangement.spacedBy(space = 2.dp), + ) { + Text( + text = title, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + + subtitle?.let { + Text( + text = subtitle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } +} + +@Composable +private fun ConversationInlineAttachmentIcon( + kind: ConversationInlineAttachmentKind, +) { + when (kind) { + ConversationInlineAttachmentKind.AUDIO -> { + Icon( + imageVector = Icons.Rounded.PlayArrow, + contentDescription = stringResource( + id = R.string.audio_play_content_description, + ), + ) + } + + ConversationInlineAttachmentKind.FILE -> { + Icon( + imageVector = Icons.Rounded.Description, + contentDescription = null, + ) + } + + ConversationInlineAttachmentKind.VCARD -> { + Icon( + imageVector = Icons.Rounded.Person, + contentDescription = null, + ) + } + } +} diff --git a/src/com/android/messaging/ui/conversation/v2/messages/ui/attachment/ConversationMessageAttachments.kt b/src/com/android/messaging/ui/conversation/v2/messages/ui/attachment/ConversationMessageAttachments.kt new file mode 100644 index 00000000..3d7d5de0 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/v2/messages/ui/attachment/ConversationMessageAttachments.kt @@ -0,0 +1,63 @@ +package com.android.messaging.ui.conversation.v2.messages.ui.attachment + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.android.messaging.ui.conversation.v2.messages.model.attachment.ConversationAttachmentItem +import com.android.messaging.ui.conversation.v2.messages.model.attachment.ConversationAttachmentSections + +@Composable +internal fun ConversationMessageAttachments( + modifier: Modifier = Modifier, + attachmentSections: ConversationAttachmentSections, + hasTextAboveVisualAttachments: Boolean, + hasTextBelowVisualAttachments: Boolean, + onAttachmentClick: (contentType: String, contentUri: String) -> Unit, + onExternalUriClick: (String) -> Unit, +) { + val hasGalleryVisualAttachments = attachmentSections.galleryVisualAttachments.isNotEmpty() + val hasTrailingItems = attachmentSections.trailingItems.isNotEmpty() + + if (!hasGalleryVisualAttachments && !hasTrailingItems) { + return + } + + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(space = 8.dp), + ) { + if (hasGalleryVisualAttachments) { + ConversationGalleryVisualAttachments( + attachments = attachmentSections.galleryVisualAttachments, + hasTextAboveVisualAttachments = hasTextAboveVisualAttachments, + hasTextBelowVisualAttachments = hasTextBelowVisualAttachments, + onAttachmentClick = onAttachmentClick, + onExternalUriClick = onExternalUriClick, + ) + } + + attachmentSections.trailingItems.forEach { trailingItem -> + when (trailingItem) { + is ConversationAttachmentItem.Inline -> { + ConversationInlineAttachmentRow( + attachment = trailingItem.attachment, + onAttachmentClick = onAttachmentClick, + onExternalUriClick = onExternalUriClick, + ) + } + + is ConversationAttachmentItem.StandaloneVisual -> { + ConversationStandaloneVisualAttachment( + attachment = trailingItem.attachment, + hasTextAboveVisualAttachments = hasTextAboveVisualAttachments, + hasTextBelowVisualAttachments = hasTextBelowVisualAttachments, + onAttachmentClick = onAttachmentClick, + onExternalUriClick = onExternalUriClick, + ) + } + } + } + } +} diff --git a/src/com/android/messaging/ui/conversation/v2/messages/ui/attachment/ConversationVisualAttachments.kt b/src/com/android/messaging/ui/conversation/v2/messages/ui/attachment/ConversationVisualAttachments.kt new file mode 100644 index 00000000..50e1568e --- /dev/null +++ b/src/com/android/messaging/ui/conversation/v2/messages/ui/attachment/ConversationVisualAttachments.kt @@ -0,0 +1,379 @@ +package com.android.messaging.ui.conversation.v2.messages.ui.attachment + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Description +import androidx.compose.material.icons.rounded.PlayArrow +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import com.android.messaging.ui.conversation.v2.mediapicker.component.ConversationMediaThumbnail +import com.android.messaging.ui.conversation.v2.messages.model.attachment.ConversationMessageAttachment +import com.android.messaging.ui.conversation.v2.messages.model.message.ConversationMessagePartUiModel +import com.android.messaging.util.ContentType +import kotlinx.collections.immutable.ImmutableList + +internal val MESSAGE_ATTACHMENT_CORNER_RADIUS = 18.dp +internal val MESSAGE_ATTACHMENT_GRID_SPACING = 6.dp +private const val MESSAGE_ATTACHMENT_DEFAULT_IMAGE_ASPECT_RATIO = 4f / 3f +private const val MESSAGE_ATTACHMENT_DEFAULT_VIDEO_ASPECT_RATIO = 16f / 9f +private const val MESSAGE_ATTACHMENT_MAX_ASPECT_RATIO = 1.8f +private const val MESSAGE_ATTACHMENT_MIN_ASPECT_RATIO = 0.75f +private const val MESSAGE_ATTACHMENT_MIN_PREVIEW_SIZE_PX = 1 + +@Composable +internal fun ConversationGalleryVisualAttachments( + attachments: ImmutableList, + hasTextAboveVisualAttachments: Boolean, + hasTextBelowVisualAttachments: Boolean, + onAttachmentClick: (contentType: String, contentUri: String) -> Unit, + onExternalUriClick: (String) -> Unit, +) { + when (attachments.size) { + 0 -> {} + 1 -> { + ConversationVisualAttachmentCard( + modifier = Modifier.fillMaxWidth(), + attachment = attachments.first(), + aspectRatio = resolveAttachmentAspectRatio( + attachment = attachments.first(), + ), + attachmentShape = visualAttachmentShape( + hasTextAboveVisualAttachments = hasTextAboveVisualAttachments, + hasTextBelowVisualAttachments = hasTextBelowVisualAttachments, + ), + onAttachmentClick = onAttachmentClick, + onExternalUriClick = onExternalUriClick, + ) + } + + else -> { + ConversationVisualAttachmentGrid( + attachments = attachments, + hasTextAboveVisualAttachments = hasTextAboveVisualAttachments, + hasTextBelowVisualAttachments = hasTextBelowVisualAttachments, + onAttachmentClick = onAttachmentClick, + onExternalUriClick = onExternalUriClick, + ) + } + } +} + +@Composable +internal fun ConversationStandaloneVisualAttachment( + attachment: ConversationMessageAttachment, + hasTextAboveVisualAttachments: Boolean, + hasTextBelowVisualAttachments: Boolean, + onAttachmentClick: (contentType: String, contentUri: String) -> Unit, + onExternalUriClick: (String) -> Unit, +) { + ConversationVisualAttachmentCard( + modifier = Modifier.fillMaxWidth(), + attachment = attachment, + aspectRatio = resolveAttachmentAspectRatio( + attachment = attachment, + ), + attachmentShape = visualAttachmentShape( + hasTextAboveVisualAttachments = hasTextAboveVisualAttachments, + hasTextBelowVisualAttachments = hasTextBelowVisualAttachments, + ), + onAttachmentClick = onAttachmentClick, + onExternalUriClick = onExternalUriClick, + ) +} + +@Composable +private fun ConversationVisualAttachmentGrid( + attachments: ImmutableList, + hasTextAboveVisualAttachments: Boolean, + hasTextBelowVisualAttachments: Boolean, + onAttachmentClick: (contentType: String, contentUri: String) -> Unit, + onExternalUriClick: (String) -> Unit, +) { + val attachmentRows = remember(attachments) { + attachments.chunked(size = 2) + } + + Column( + verticalArrangement = Arrangement.spacedBy(space = MESSAGE_ATTACHMENT_GRID_SPACING), + ) { + attachmentRows.forEachIndexed { rowIndex, attachmentRow -> + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy( + space = MESSAGE_ATTACHMENT_GRID_SPACING, + ), + ) { + attachmentRow.forEachIndexed { columnIndex, attachment -> + Box( + modifier = Modifier.weight(weight = 1f), + ) { + ConversationVisualAttachmentCard( + modifier = Modifier.fillMaxWidth(), + attachment = attachment, + aspectRatio = 1f, + attachmentShape = visualAttachmentShape( + hasTextAboveVisualAttachments = hasTextAboveVisualAttachments && + rowIndex == 0, + hasTextBelowVisualAttachments = hasTextBelowVisualAttachments && + rowIndex == attachmentRows.lastIndex, + hasRoundedStartCorners = columnIndex == 0, + hasRoundedEndCorners = columnIndex == attachmentRow.lastIndex, + ), + onAttachmentClick = onAttachmentClick, + onExternalUriClick = onExternalUriClick, + ) + } + } + + if (attachmentRow.size == 1) { + Box( + modifier = Modifier.weight(weight = 1f), + ) + } + } + } + } +} + +@Composable +private fun ConversationVisualAttachmentCard( + modifier: Modifier, + attachment: ConversationMessageAttachment, + aspectRatio: Float, + attachmentShape: RoundedCornerShape, + onAttachmentClick: (contentType: String, contentUri: String) -> Unit, + onExternalUriClick: (String) -> Unit, +) { + ConversationVisualAttachmentSurface( + modifier = modifier.aspectRatio(ratio = aspectRatio), + attachment = attachment, + attachmentShape = attachmentShape, + contentScale = ContentScale.Crop, + onAttachmentClick = onAttachmentClick, + onExternalUriClick = onExternalUriClick, + overlay = { + if (attachment.requiresPlaybackAffordance()) { + CenterPlayAffordance() + } + }, + ) +} + +@Composable +private fun ConversationVisualAttachmentSurface( + modifier: Modifier, + attachment: ConversationMessageAttachment, + attachmentShape: RoundedCornerShape, + contentScale: ContentScale, + onAttachmentClick: (contentType: String, contentUri: String) -> Unit, + onExternalUriClick: (String) -> Unit, + overlay: @Composable BoxScope.() -> Unit, +) { + val density = LocalDensity.current + val openAction = remember(attachment) { + attachment.toConversationAttachmentOpenActionOrNull() + } + + Surface( + modifier = modifier + .clip(shape = attachmentShape) + .clickable( + enabled = openAction != null, + onClick = { + openAction?.let { action -> + dispatchConversationAttachmentOpenAction( + action = action, + onAttachmentClick = onAttachmentClick, + onExternalUriClick = onExternalUriClick, + ) + } + }, + ), + shape = attachmentShape, + color = MaterialTheme.colorScheme.surfaceContainerHighest, + ) { + BoxWithConstraints( + modifier = Modifier.fillMaxSize(), + ) { + val thumbnailSize = remember(maxWidth, maxHeight, density) { + with(density) { + IntSize( + width = maxWidth.roundToPx().coerceAtLeast( + minimumValue = MESSAGE_ATTACHMENT_MIN_PREVIEW_SIZE_PX, + ), + height = maxHeight.roundToPx().coerceAtLeast( + minimumValue = MESSAGE_ATTACHMENT_MIN_PREVIEW_SIZE_PX, + ), + ) + } + } + + ConversationAttachmentThumbnail( + modifier = Modifier.fillMaxSize(), + attachment = attachment, + contentScale = contentScale, + thumbnailSize = thumbnailSize, + ) + + overlay() + } + } +} + +@Composable +private fun ConversationAttachmentThumbnail( + modifier: Modifier, + attachment: ConversationMessageAttachment, + contentScale: ContentScale, + thumbnailSize: IntSize, +) { + when (attachment) { + is ConversationMessageAttachment.Media -> { + ConversationMediaThumbnail( + modifier = modifier, + contentUri = attachment.part.contentUri.toString(), + contentType = attachment.part.contentType, + size = thumbnailSize, + contentScale = contentScale, + ) + } + + is ConversationMessageAttachment.YouTubePreview -> { + ConversationMediaThumbnail( + modifier = modifier, + contentUri = attachment.thumbnailUrl, + contentType = ContentType.IMAGE_JPEG, + size = thumbnailSize, + contentScale = contentScale, + ) + } + + is ConversationMessageAttachment.Unsupported -> { + Box( + modifier = modifier.background( + color = MaterialTheme.colorScheme.surfaceContainerHighest, + ), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Rounded.Description, + contentDescription = null, + ) + } + } + } +} + +private fun visualAttachmentShape( + hasTextAboveVisualAttachments: Boolean, + hasTextBelowVisualAttachments: Boolean, + hasRoundedStartCorners: Boolean = true, + hasRoundedEndCorners: Boolean = true, +): RoundedCornerShape { + return RoundedCornerShape( + topStart = roundedAttachmentCornerSize( + shouldRoundCorner = hasRoundedStartCorners && !hasTextAboveVisualAttachments, + ), + topEnd = roundedAttachmentCornerSize( + shouldRoundCorner = hasRoundedEndCorners && !hasTextAboveVisualAttachments, + ), + bottomStart = roundedAttachmentCornerSize( + shouldRoundCorner = hasRoundedStartCorners && !hasTextBelowVisualAttachments, + ), + bottomEnd = roundedAttachmentCornerSize( + shouldRoundCorner = hasRoundedEndCorners && !hasTextBelowVisualAttachments, + ), + ) +} + +private fun roundedAttachmentCornerSize(shouldRoundCorner: Boolean): Dp { + return when { + shouldRoundCorner -> MESSAGE_ATTACHMENT_CORNER_RADIUS + else -> 0.dp + } +} + +@Composable +private fun BoxScope.CenterPlayAffordance() { + Surface( + color = MaterialTheme.colorScheme.surface.copy(alpha = 0.9f), + shape = RoundedCornerShape(size = 999.dp), + modifier = Modifier.align(alignment = Alignment.Center), + ) { + Icon( + imageVector = Icons.Rounded.PlayArrow, + contentDescription = null, + modifier = Modifier + .padding(all = 10.dp) + .size(size = 26.dp), + tint = MaterialTheme.colorScheme.onSurface, + ) + } +} + +private fun ConversationMessageAttachment.requiresPlaybackAffordance(): Boolean { + return when (this) { + is ConversationMessageAttachment.Media -> part.isVideoAttachment + is ConversationMessageAttachment.YouTubePreview -> true + is ConversationMessageAttachment.Unsupported -> false + } +} + +private fun resolveAttachmentAspectRatio( + attachment: ConversationMessageAttachment, +): Float { + val preferredAspectRatio = when (attachment) { + is ConversationMessageAttachment.Media -> { + resolvePartAspectRatio(part = attachment.part) + } + + is ConversationMessageAttachment.YouTubePreview -> { + MESSAGE_ATTACHMENT_DEFAULT_VIDEO_ASPECT_RATIO + } + + is ConversationMessageAttachment.Unsupported -> { + MESSAGE_ATTACHMENT_DEFAULT_IMAGE_ASPECT_RATIO + } + } + + return preferredAspectRatio.coerceIn( + minimumValue = MESSAGE_ATTACHMENT_MIN_ASPECT_RATIO, + maximumValue = MESSAGE_ATTACHMENT_MAX_ASPECT_RATIO, + ) +} + +private fun resolvePartAspectRatio( + part: ConversationMessagePartUiModel, +): Float { + val hasMeasuredSize = part.width > 0 && part.height > 0 + + return when { + hasMeasuredSize -> part.width.toFloat() / part.height.toFloat() + part.isVideoAttachment -> MESSAGE_ATTACHMENT_DEFAULT_VIDEO_ASPECT_RATIO + else -> MESSAGE_ATTACHMENT_DEFAULT_IMAGE_ASPECT_RATIO + } +} diff --git a/src/com/android/messaging/ui/conversation/v2/messages/ui/ConversationMessage.kt b/src/com/android/messaging/ui/conversation/v2/messages/ui/message/ConversationMessage.kt similarity index 50% rename from src/com/android/messaging/ui/conversation/v2/messages/ui/ConversationMessage.kt rename to src/com/android/messaging/ui/conversation/v2/messages/ui/message/ConversationMessage.kt index a3049b4d..e022ebc5 100644 --- a/src/com/android/messaging/ui/conversation/v2/messages/ui/ConversationMessage.kt +++ b/src/com/android/messaging/ui/conversation/v2/messages/ui/message/ConversationMessage.kt @@ -1,4 +1,4 @@ -package com.android.messaging.ui.conversation.v2.messages.ui +package com.android.messaging.ui.conversation.v2.messages.ui.message import android.content.Context import android.text.format.DateUtils @@ -18,36 +18,49 @@ import androidx.compose.runtime.Immutable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.android.messaging.R -import com.android.messaging.ui.conversation.v2.messages.model.ConversationMessageUiModel -import com.android.messaging.ui.conversation.v2.messages.model.ConversationMessageUiModel.Status +import com.android.messaging.sms.cleanseMmsSubject +import com.android.messaging.ui.conversation.v2.messages.model.message.ConversationMessageContent +import com.android.messaging.ui.conversation.v2.messages.model.message.ConversationMessageUiModel +import com.android.messaging.ui.conversation.v2.messages.model.message.ConversationMessageUiModel.Status +import com.android.messaging.ui.conversation.v2.messages.ui.attachment.ConversationMessageAttachments +import com.android.messaging.ui.conversation.v2.messages.ui.text.ConversationMessageText private const val MESSAGE_BUBBLE_MAX_WIDTH_DP = 360 private const val MESSAGE_BUBBLE_WIDTH_FRACTION = 0.8f private const val MESSAGE_BUBBLE_CORNER_RADIUS_DP = 24 private const val MESSAGE_BUBBLE_CONNECTED_CORNER_RADIUS_DP = 6 +private val MESSAGE_BUBBLE_MEDIA_SECTION_SPACING = 8.dp +private val MESSAGE_BUBBLE_MEDIA_TEXT_PADDING = 12.dp +private val MESSAGE_BUBBLE_TEXT_HORIZONTAL_PADDING = 16.dp +private val MESSAGE_BUBBLE_TEXT_VERTICAL_PADDING = 12.dp @Composable internal fun ConversationMessage( modifier: Modifier = Modifier, message: ConversationMessageUiModel, + onAttachmentClick: (contentType: String, contentUri: String) -> Unit = { _, _ -> }, + onExternalUriClick: (String) -> Unit = {}, ) { BoxWithConstraints( - modifier = modifier.fillMaxWidth(), + modifier = modifier + .fillMaxWidth(), ) { val maxBubbleWidth = remember(maxWidth) { (maxWidth * MESSAGE_BUBBLE_WIDTH_FRACTION) .coerceAtMost(MESSAGE_BUBBLE_MAX_WIDTH_DP.dp) } - val presentation = rememberConversationMessagePresentation(message = message) + val layout = rememberConversationMessageLayout(message = message) Row( modifier = Modifier.fillMaxWidth(), @@ -55,26 +68,36 @@ internal fun ConversationMessage( ) { ConversationMessageContent( message = message, - presentation = presentation, + layout = layout, maxBubbleWidth = maxBubbleWidth, + onAttachmentClick = onAttachmentClick, + onExternalUriClick = onExternalUriClick, ) } } } @Immutable -private data class ConversationMessagePresentation( +private data class ConversationMessageLayout( val bubbleShape: RoundedCornerShape, - val bodyText: String, + val bubbleLayoutMode: ConversationMessageBubbleLayoutMode, + val content: ConversationMessageContent, val metadataText: String?, val showSender: Boolean, ) +private enum class ConversationMessageBubbleLayoutMode { + AttachmentOnlyWithoutSurface, + AttachmentsInSurface, + TextInSurface, +} + @Composable -private fun rememberConversationMessagePresentation( +private fun rememberConversationMessageLayout( message: ConversationMessageUiModel, -): ConversationMessagePresentation { +): ConversationMessageLayout { val context = LocalContext.current + val resources = LocalResources.current val configuration = LocalConfiguration.current val bubbleShape = remember( @@ -84,18 +107,33 @@ private fun rememberConversationMessagePresentation( messageBubbleShape(message = message) } - val bodyText = remember( + val subjectText = remember( + resources, + configuration, + message.mmsSubject, + ) { + cleanseMmsSubject( + resources = resources, + subject = message.mmsSubject, + ) + } + + val content = remember( message.text, message.mmsSubject, message.parts, + subjectText, ) { - buildMessageBody(message = message) + buildConversationMessageContent( + message = message, + subjectText = subjectText, + ) } val statusTextResourceId = remember(message.status) { messageStatusTextResourceId(status = message.status) } - val statusText = statusTextResourceId?.let { stringResource(id = it) } + val statusText = statusTextResourceId?.let { stringResource(it) } val metadataText = remember( context, @@ -122,15 +160,27 @@ private fun rememberConversationMessagePresentation( !message.canClusterWithPrevious } + val bubbleLayoutMode = remember( + content, + showSender, + ) { + buildConversationMessageBubbleLayoutMode( + content = content, + showSender = showSender, + ) + } + return remember( bubbleShape, - bodyText, + bubbleLayoutMode, + content, metadataText, showSender, ) { - ConversationMessagePresentation( + ConversationMessageLayout( bubbleShape = bubbleShape, - bodyText = bodyText, + bubbleLayoutMode = bubbleLayoutMode, + content = content, metadataText = metadataText, showSender = showSender, ) @@ -149,22 +199,27 @@ private fun messageHorizontalArrangement( @Composable private fun ConversationMessageContent( message: ConversationMessageUiModel, - presentation: ConversationMessagePresentation, + layout: ConversationMessageLayout, maxBubbleWidth: Dp, + onAttachmentClick: (contentType: String, contentUri: String) -> Unit, + onExternalUriClick: (String) -> Unit, ) { Column( - modifier = Modifier.widthIn(max = maxBubbleWidth), + modifier = Modifier + .widthIn(max = maxBubbleWidth), horizontalAlignment = messageContentHorizontalAlignment(message = message), ) { ConversationMessageBubble( message = message, - presentation = presentation, + layout = layout, maxBubbleWidth = maxBubbleWidth, + onAttachmentClick = onAttachmentClick, + onExternalUriClick = onExternalUriClick, ) ConversationMessageMetadata( message = message, - metadataText = presentation.metadataText, + metadataText = layout.metadataText, ) } } @@ -172,34 +227,202 @@ private fun ConversationMessageContent( @Composable private fun ConversationMessageBubble( message: ConversationMessageUiModel, - presentation: ConversationMessagePresentation, + layout: ConversationMessageLayout, maxBubbleWidth: Dp, + onAttachmentClick: (contentType: String, contentUri: String) -> Unit, + onExternalUriClick: (String) -> Unit, +) { + when (layout.bubbleLayoutMode) { + ConversationMessageBubbleLayoutMode.AttachmentOnlyWithoutSurface -> { + ConversationMessageAttachmentBubbleContent( + modifier = Modifier + .widthIn(max = maxBubbleWidth) + .clip(shape = layout.bubbleShape), + content = layout.content, + senderDisplayName = message.senderDisplayName, + showSender = layout.showSender, + onAttachmentClick = onAttachmentClick, + onExternalUriClick = onExternalUriClick, + ) + } + + ConversationMessageBubbleLayoutMode.AttachmentsInSurface -> { + ConversationMessageBubbleSurface( + message = message, + layout = layout, + maxBubbleWidth = maxBubbleWidth, + ) { + ConversationMessageAttachmentBubbleContent( + content = layout.content, + senderDisplayName = message.senderDisplayName, + showSender = layout.showSender, + onAttachmentClick = onAttachmentClick, + onExternalUriClick = onExternalUriClick, + ) + } + } + + ConversationMessageBubbleLayoutMode.TextInSurface -> { + ConversationMessageBubbleSurface( + message = message, + layout = layout, + maxBubbleWidth = maxBubbleWidth, + ) { + ConversationMessageTextBubbleContent( + content = layout.content, + senderDisplayName = message.senderDisplayName, + showSender = layout.showSender, + onAttachmentClick = onAttachmentClick, + onExternalUriClick = onExternalUriClick, + ) + } + } + } +} + +@Composable +private fun ConversationMessageBubbleSurface( + message: ConversationMessageUiModel, + layout: ConversationMessageLayout, + maxBubbleWidth: Dp, + bubbleContent: @Composable () -> Unit, ) { Surface( color = messageBubbleColor(message = message), contentColor = messageBubbleContentColor(message = message), - shape = presentation.bubbleShape, + shape = layout.bubbleShape, modifier = Modifier.widthIn(max = maxBubbleWidth), ) { - Column( - modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), - verticalArrangement = Arrangement.spacedBy(space = 4.dp), - ) { - ConversationMessageSender( - senderDisplayName = message.senderDisplayName, - showSender = presentation.showSender, - ) + bubbleContent() + } +} + +@Composable +private fun ConversationMessageTextBubbleContent( + content: ConversationMessageContent, + senderDisplayName: String?, + showSender: Boolean, + onAttachmentClick: (contentType: String, contentUri: String) -> Unit, + onExternalUriClick: (String) -> Unit, +) { + Column( + modifier = Modifier.padding( + horizontal = MESSAGE_BUBBLE_TEXT_HORIZONTAL_PADDING, + vertical = MESSAGE_BUBBLE_TEXT_VERTICAL_PADDING, + ), + verticalArrangement = Arrangement.spacedBy(space = 8.dp), + ) { + ConversationMessageSender( + senderDisplayName = senderDisplayName, + showSender = showSender, + ) + ConversationMessageBody( + content = content, + onAttachmentClick = onAttachmentClick, + onExternalUriClick = onExternalUriClick, + ) + } +} + +@Composable +private fun ConversationMessageAttachmentBubbleContent( + modifier: Modifier = Modifier, + content: ConversationMessageContent, + senderDisplayName: String?, + showSender: Boolean, + onAttachmentClick: (contentType: String, contentUri: String) -> Unit, + onExternalUriClick: (String) -> Unit, +) { + val hasHeader = showSender || !content.subjectText.isNullOrBlank() + val hasBodyText = !content.bodyText.isNullOrBlank() + + Column( + modifier = modifier.fillMaxWidth(), + ) { + ConversationMessageSender( + modifier = Modifier.padding( + start = MESSAGE_BUBBLE_MEDIA_TEXT_PADDING, + top = MESSAGE_BUBBLE_MEDIA_TEXT_PADDING, + end = MESSAGE_BUBBLE_MEDIA_TEXT_PADDING, + bottom = when { + content.subjectText.isNullOrBlank() -> 6.dp + else -> MESSAGE_BUBBLE_MEDIA_SECTION_SPACING + }, + ), + senderDisplayName = senderDisplayName, + showSender = showSender, + ) + + content.subjectText?.let { subjectText -> Text( - text = presentation.bodyText, + modifier = Modifier.padding( + start = MESSAGE_BUBBLE_MEDIA_TEXT_PADDING, + end = MESSAGE_BUBBLE_MEDIA_TEXT_PADDING, + bottom = MESSAGE_BUBBLE_MEDIA_SECTION_SPACING, + ), + text = subjectText, + style = MaterialTheme.typography.titleSmall, + ) + } + + ConversationMessageAttachments( + attachmentSections = content.attachmentSections, + hasTextAboveVisualAttachments = hasHeader, + hasTextBelowVisualAttachments = hasBodyText, + onAttachmentClick = onAttachmentClick, + onExternalUriClick = onExternalUriClick, + ) + + content.bodyText?.let { bodyText -> + ConversationMessageText( + modifier = Modifier.padding( + start = MESSAGE_BUBBLE_MEDIA_TEXT_PADDING, + top = MESSAGE_BUBBLE_MEDIA_SECTION_SPACING, + end = MESSAGE_BUBBLE_MEDIA_TEXT_PADDING, + bottom = MESSAGE_BUBBLE_MEDIA_TEXT_PADDING, + ), + text = bodyText, style = MaterialTheme.typography.bodyLarge, + onExternalUriClick = onExternalUriClick, ) } } } +@Composable +private fun ConversationMessageBody( + content: ConversationMessageContent, + onAttachmentClick: (contentType: String, contentUri: String) -> Unit, + onExternalUriClick: (String) -> Unit, +) { + content.subjectText?.let { subjectText -> + Text( + text = subjectText, + style = MaterialTheme.typography.titleSmall, + ) + } + + ConversationMessageAttachments( + attachmentSections = content.attachmentSections, + hasTextAboveVisualAttachments = false, + hasTextBelowVisualAttachments = false, + onAttachmentClick = onAttachmentClick, + onExternalUriClick = onExternalUriClick, + ) + + content.bodyText?.let { bodyText -> + ConversationMessageText( + text = bodyText, + style = MaterialTheme.typography.bodyLarge, + onExternalUriClick = onExternalUriClick, + ) + } +} + @Composable private fun ConversationMessageSender( + modifier: Modifier = Modifier, senderDisplayName: String?, showSender: Boolean, ) { @@ -208,6 +431,7 @@ private fun ConversationMessageSender( } Text( + modifier = modifier, text = senderDisplayName, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.primary, @@ -226,13 +450,13 @@ private fun ConversationMessageMetadata( } Text( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 6.dp), text = metadataText, style = MaterialTheme.typography.labelSmall, color = messageMetadataColor(message = message), textAlign = messageMetadataTextAlign(message = message), - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 6.dp), ) } @@ -312,25 +536,25 @@ private fun clusteredCornerRadius( return MESSAGE_BUBBLE_CONNECTED_CORNER_RADIUS_DP.dp } -private fun buildMessageBody(message: ConversationMessageUiModel): String { - message - .text - ?.takeIf { it.isNotBlank() } - ?.let { return it } - - message - .mmsSubject - ?.takeIf { it.isNotBlank() } - ?.let { return it } - - message - .parts - .firstNotNullOfOrNull { part -> - part.text?.takeIf { it.isNotBlank() } - } - ?.let { return it } +private fun buildConversationMessageBubbleLayoutMode( + content: ConversationMessageContent, + showSender: Boolean, +): ConversationMessageBubbleLayoutMode { + val hasAttachments = content.attachments.isNotEmpty() + if (!hasAttachments) { + return ConversationMessageBubbleLayoutMode.TextInSurface + } + + val hasAttachmentHeaderOrFooter = showSender || + !content.subjectText.isNullOrBlank() || + !content.bodyText.isNullOrBlank() - return message.parts.firstOrNull()?.contentType.orEmpty() + return when { + content.isAttachmentOnly && !hasAttachmentHeaderOrFooter -> { + ConversationMessageBubbleLayoutMode.AttachmentOnlyWithoutSurface + } + else -> ConversationMessageBubbleLayoutMode.AttachmentsInSurface + } } private fun buildMessageMetadataText( @@ -380,7 +604,9 @@ private fun messageStatusTextResourceId(status: Status): Int? { } @Composable -private fun messageMetadataColor(message: ConversationMessageUiModel): Color { +private fun messageMetadataColor( + message: ConversationMessageUiModel, +): Color { return when (message.status) { Status.Outgoing.AwaitingRetry, Status.Outgoing.Failed, diff --git a/src/com/android/messaging/ui/conversation/v2/messages/ui/message/ConversationMessageContentBuilder.kt b/src/com/android/messaging/ui/conversation/v2/messages/ui/message/ConversationMessageContentBuilder.kt new file mode 100644 index 00000000..c75fa641 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/v2/messages/ui/message/ConversationMessageContentBuilder.kt @@ -0,0 +1,173 @@ +package com.android.messaging.ui.conversation.v2.messages.ui.message + +import android.net.Uri +import android.util.Patterns +import android.webkit.URLUtil +import com.android.messaging.ui.conversation.v2.messages.model.attachment.ConversationMessageAttachment +import com.android.messaging.ui.conversation.v2.messages.model.message.ConversationMessageContent +import com.android.messaging.ui.conversation.v2.messages.model.message.ConversationMessagePartUiModel +import com.android.messaging.ui.conversation.v2.messages.model.message.ConversationMessageUiModel +import com.android.messaging.ui.conversation.v2.messages.ui.attachment.buildConversationAttachmentSections +import com.android.messaging.util.YouTubeUtil +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList + +internal fun buildConversationMessageContent( + message: ConversationMessageUiModel, + subjectText: String?, +): ConversationMessageContent { + val attachments = buildConversationMessageAttachments(message = message) + val attachmentSections = buildConversationAttachmentSections(attachments = attachments) + + val bodyText = buildConversationMessageBodyText( + message = message, + attachments = attachments, + ) + + val isAttachmentOnly = subjectText.isNullOrBlank() && + bodyText.isNullOrBlank() && + attachments.isNotEmpty() + + return ConversationMessageContent( + subjectText = subjectText, + bodyText = bodyText, + attachments = attachments, + attachmentSections = attachmentSections, + isAttachmentOnly = isAttachmentOnly, + ) +} + +private fun buildConversationMessageAttachments( + message: ConversationMessageUiModel, +): ImmutableList { + val attachmentItems = message + .parts + .mapIndexedNotNull(::toConversationMessageAttachment) + .toImmutableList() + + val hasImageAttachment = attachmentItems.any { attachment -> + attachment is ConversationMessageAttachment.Media && attachment.part.isImageAttachment + } + + if (hasImageAttachment) { + return attachmentItems + } + + return message.text + ?.let(::findSingleYouTubePreview) + ?.let { youtubePreview -> + (attachmentItems + youtubePreview).toImmutableList() + } + ?: attachmentItems +} + +private fun toConversationMessageAttachment( + index: Int, + part: ConversationMessagePartUiModel, +): ConversationMessageAttachment? { + if (!part.isMediaAttachment) { + return null + } + + val key = buildConversationMessageAttachmentKey( + index = index, + contentType = part.contentType, + contentUri = part.contentUri, + ) + + return when { + part.isSupportedAttachment && part.hasRenderableContentUri -> { + ConversationMessageAttachment.Media( + key = key, + part = part, + ) + } + + else -> { + ConversationMessageAttachment.Unsupported( + key = key, + part = part, + ) + } + } +} + +private fun buildConversationMessageAttachmentKey( + index: Int, + contentType: String, + contentUri: Uri?, +): String { + return buildString { + append(index) + append(':') + append(contentType) + append(':') + append(contentUri ?: "missing") + } +} + +private fun buildConversationMessageBodyText( + message: ConversationMessageUiModel, + attachments: ImmutableList, +): String? { + message.text + ?.trim() + ?.takeIf { it.isNotEmpty() } + ?.let { bodyText -> + return bodyText + } + + val captionText = message.parts + .asSequence() + .filter { part -> part.hasCaptionText } + .mapNotNull { part -> + part.text?.trim()?.takeIf { text -> text.isNotEmpty() } + } + .distinct() + .joinToString(separator = "\n") + .takeIf { text -> text.isNotEmpty() } + + return when { + captionText != null -> captionText + attachments.isNotEmpty() -> { + message.parts.firstOrNull()?.contentType?.takeIf { it.isNotBlank() } + } + + else -> null + } +} + +private fun findSingleYouTubePreview( + text: String, +): ConversationMessageAttachment.YouTubePreview? { + return extractConversationWebUrls(text) + .asSequence() + .mapNotNull { sourceUrl -> + val thumbnailUrl = YouTubeUtil + .getYoutubePreviewImageLink(sourceUrl) + ?: return@mapNotNull null + + ConversationMessageAttachment.YouTubePreview( + key = "youtube:$sourceUrl", + sourceUrl = sourceUrl, + thumbnailUrl = thumbnailUrl, + ) + } + .take(2) + .singleOrNull() +} + +private fun extractConversationWebUrls(text: String): Set { + val webUrlMatcher = Patterns.WEB_URL.matcher(text) + val urls = LinkedHashSet() + + while (webUrlMatcher.find()) { + webUrlMatcher + .group() + .takeIf { it.isNotBlank() } + ?.let(URLUtil::guessUrl) + ?.let(urls::add) + } + + return urls +} diff --git a/src/com/android/messaging/ui/conversation/v2/messages/ui/message/ConversationMessageDateFormatting.kt b/src/com/android/messaging/ui/conversation/v2/messages/ui/message/ConversationMessageDateFormatting.kt new file mode 100644 index 00000000..8a540403 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/v2/messages/ui/message/ConversationMessageDateFormatting.kt @@ -0,0 +1,70 @@ +package com.android.messaging.ui.conversation.v2.messages.ui.message + +import android.content.Context +import android.text.format.DateUtils +import com.android.messaging.ui.conversation.v2.messages.model.message.ConversationMessageUiModel +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneId +import java.util.TimeZone + +private const val MILLIS_PER_DAY = 86_400_000L + +private const val COMMON_DATE_SEPARATOR_FORMAT_FLAGS = DateUtils.FORMAT_SHOW_WEEKDAY or + DateUtils.FORMAT_SHOW_DATE or + DateUtils.FORMAT_ABBREV_MONTH + +internal fun conversationMessageDisplayEpochDay( + displayTimestamp: Long, + timeZone: TimeZone, +): Long? { + return when { + displayTimestamp <= 0 -> null + + else -> { + val localTimestamp = displayTimestamp + timeZone.getOffset(displayTimestamp) + Math.floorDiv(localTimestamp, MILLIS_PER_DAY) + } + } +} + +private fun conversationMessageDisplayLocalDate( + displayTimestamp: Long, +): LocalDate? { + return when { + displayTimestamp > 0 -> { + Instant + .ofEpochMilli(displayTimestamp) + .atZone(ZoneId.systemDefault()) + .toLocalDate() + } + + else -> null + } +} + +internal fun formatDateSeparatorText( + context: Context, + message: ConversationMessageUiModel, +): String? { + val timestamp = message.displayTimestamp + + if (timestamp <= 0L) { + return null + } + + val isSameYear = conversationMessageDisplayLocalDate( + displayTimestamp = timestamp, + )?.year == LocalDate.now().year + + val dateTimeFormatFlags = when { + isSameYear -> COMMON_DATE_SEPARATOR_FORMAT_FLAGS or DateUtils.FORMAT_NO_YEAR + else -> COMMON_DATE_SEPARATOR_FORMAT_FLAGS or DateUtils.FORMAT_SHOW_YEAR + } + + return DateUtils.formatDateTime( + context, + timestamp, + dateTimeFormatFlags, + ) +} diff --git a/src/com/android/messaging/ui/conversation/v2/messages/ui/text/ConversationMessageText.kt b/src/com/android/messaging/ui/conversation/v2/messages/ui/text/ConversationMessageText.kt new file mode 100644 index 00000000..1e24aab2 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/v2/messages/ui/text/ConversationMessageText.kt @@ -0,0 +1,106 @@ +package com.android.messaging.ui.conversation.v2.messages.ui.text + +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.LinkAnnotation +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextLinkStyles +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.withLink +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +@Composable +internal fun ConversationMessageText( + text: String, + style: TextStyle, + onExternalUriClick: (String) -> Unit, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + val linkColor = MaterialTheme.colorScheme.primary + val linkStyle = remember(linkColor) { + TextLinkStyles( + style = SpanStyle( + color = linkColor, + textDecoration = TextDecoration.Underline, + ), + ) + } + + val textWithLinks by produceState( + initialValue = AnnotatedString(text = text), + text, + linkStyle, + onExternalUriClick, + context.applicationContext, + ) { + val links = withContext(Dispatchers.IO) { + extractConversationTextLinks( + context = context.applicationContext, + text = text, + ) + } + + value = buildConversationLinkedAnnotatedString( + text = text, + links = links, + linkStyle = linkStyle, + onExternalUriClick = onExternalUriClick, + ) + } + + Text( + text = textWithLinks, + style = style, + modifier = modifier, + ) +} + +private fun buildConversationLinkedAnnotatedString( + text: String, + links: List, + linkStyle: TextLinkStyles, + onExternalUriClick: (String) -> Unit, +): AnnotatedString { + if (links.isEmpty()) { + return AnnotatedString(text) + } + + return buildAnnotatedString { + var currentIndex = 0 + + links.forEach { link -> + if (link.start > currentIndex) { + append(text.substring(currentIndex, link.start)) + } + + withLink( + link = LinkAnnotation.Url( + url = link.url, + styles = linkStyle, + linkInteractionListener = { _ -> + onExternalUriClick(link.url) + }, + ), + ) { + append(text.substring(link.start, link.end)) + } + + currentIndex = link.end + } + + if (currentIndex < text.length) { + append(text.substring(currentIndex)) + } + } +} diff --git a/src/com/android/messaging/ui/conversation/v2/messages/ui/text/ConversationMessageTextLinkExtractor.kt b/src/com/android/messaging/ui/conversation/v2/messages/ui/text/ConversationMessageTextLinkExtractor.kt new file mode 100644 index 00000000..645aff1a --- /dev/null +++ b/src/com/android/messaging/ui/conversation/v2/messages/ui/text/ConversationMessageTextLinkExtractor.kt @@ -0,0 +1,117 @@ +package com.android.messaging.ui.conversation.v2.messages.ui.text + +import android.content.Context +import android.net.Uri +import android.view.textclassifier.TextClassificationManager +import android.view.textclassifier.TextClassifier +import android.view.textclassifier.TextLinks +import android.webkit.URLUtil +import androidx.compose.runtime.Immutable + +@Immutable +internal data class ConversationTextLink( + val start: Int, + val end: Int, + val url: String, +) + +private data class ConversationLinkText( + val start: Int, + val end: Int, + val entityType: String, + val rawLinkText: String, +) + +internal fun extractConversationTextLinks( + context: Context, + text: String, +): List { + if (text.isBlank()) { + return emptyList() + } + + val request = TextLinks.Request.Builder(text) + .setEntityConfig(CONVERSATION_TEXT_LINK_ENTITY_CONFIG) + .build() + + val textClassifier = context + .getSystemService(TextClassificationManager::class.java) + ?.textClassifier + ?: TextClassifier.NO_OP + + return textClassifier.generateLinks(request) + .links + .asSequence() + .mapNotNull { textLink -> + textLink.toConversationTextLink(text = text) + } + .sortedBy { it.start } + .toList() +} + +private fun TextLinks.TextLink.toConversationTextLink( + text: String, +): ConversationTextLink? { + return toValidatedConversationLinkText(text = text) + ?.let { linkText -> + resolveConversationTextLinkUrl( + entityType = linkText.entityType, + rawLinkText = linkText.rawLinkText, + )?.let { url -> + ConversationTextLink( + start = linkText.start, + end = linkText.end, + url = url, + ) + } + } +} + +private fun TextLinks.TextLink.toValidatedConversationLinkText( + text: String, +): ConversationLinkText? { + val isValidLinkText = start in 0.. 0 + + return when { + isValidLinkText -> { + text + .substring(startIndex = start, endIndex = end) + .takeIf { it.isNotBlank() } + ?.let { rawLinkText -> + ConversationLinkText( + start = start, + end = end, + entityType = getEntity(0), + rawLinkText = rawLinkText, + ) + } + } + + else -> null + } +} + +private fun resolveConversationTextLinkUrl( + entityType: String, + rawLinkText: String, +): String? { + return when (entityType) { + TextClassifier.TYPE_ADDRESS -> "geo:0,0?q=${Uri.encode(rawLinkText)}" + TextClassifier.TYPE_EMAIL -> "mailto:${Uri.encode(rawLinkText)}" + TextClassifier.TYPE_PHONE -> "tel:${Uri.encode(rawLinkText)}" + TextClassifier.TYPE_URL -> URLUtil.guessUrl(rawLinkText) + else -> null + } +} + +private val CONVERSATION_TEXT_LINK_ENTITY_CONFIG = TextClassifier.EntityConfig.Builder() + .setIncludedTypes( + listOf( + TextClassifier.TYPE_ADDRESS, + TextClassifier.TYPE_EMAIL, + TextClassifier.TYPE_PHONE, + TextClassifier.TYPE_URL, + ), + ) + .includeTypesFromTextClassifier(false) + .build() diff --git a/src/com/android/messaging/ui/conversation/v2/screen/ConversationScreen.kt b/src/com/android/messaging/ui/conversation/v2/screen/ConversationScreen.kt index 80973b31..8f3dc2bf 100644 --- a/src/com/android/messaging/ui/conversation/v2/screen/ConversationScreen.kt +++ b/src/com/android/messaging/ui/conversation/v2/screen/ConversationScreen.kt @@ -28,8 +28,8 @@ import com.android.messaging.ui.conversation.v2.composer.model.ConversationCompo import com.android.messaging.ui.conversation.v2.composer.ui.ConversationComposerSection import com.android.messaging.ui.conversation.v2.mediapicker.ConversationMediaPickerOverlay import com.android.messaging.ui.conversation.v2.mediapicker.rememberConversationMediaPickerState -import com.android.messaging.ui.conversation.v2.messages.model.ConversationMessageUiModel -import com.android.messaging.ui.conversation.v2.messages.model.ConversationMessagesUiState +import com.android.messaging.ui.conversation.v2.messages.model.message.ConversationMessageUiModel +import com.android.messaging.ui.conversation.v2.messages.model.message.ConversationMessagesUiState import com.android.messaging.ui.conversation.v2.messages.ui.ConversationMessages import com.android.messaging.ui.conversation.v2.metadata.ui.ConversationTopAppBar import com.android.messaging.ui.conversation.v2.screen.model.ConversationScreenScaffoldUiState @@ -79,6 +79,8 @@ internal fun ConversationScreen( onResolvedAttachmentClick = screenModel::onAttachmentClicked, onResolvedAttachmentRemove = screenModel::onRemoveResolvedAttachment, onSendClick = screenModel::onSendClick, + onAttachmentClick = screenModel::onMessageAttachmentClicked, + onExternalUriClick = screenModel::onExternalUriClicked, ) ConversationMediaPickerOverlay( @@ -115,6 +117,8 @@ private fun ConversationScreenScaffold( onResolvedAttachmentClick: (ConversationComposerAttachmentUiState.Resolved) -> Unit, onResolvedAttachmentRemove: (String) -> Unit, onSendClick: () -> Unit, + onAttachmentClick: (contentType: String, contentUri: String) -> Unit, + onExternalUriClick: (String) -> Unit, ) { Scaffold( modifier = modifier, @@ -148,6 +152,8 @@ private fun ConversationScreenScaffold( conversationId = conversationId, uiState = uiState, contentPadding = contentPadding, + onAttachmentClick = onAttachmentClick, + onExternalUriClick = onExternalUriClick, ) } } @@ -158,6 +164,8 @@ private fun ConversationScreenContent( conversationId: String?, uiState: ConversationScreenScaffoldUiState, contentPadding: PaddingValues, + onAttachmentClick: (contentType: String, contentUri: String) -> Unit, + onExternalUriClick: (String) -> Unit, ) { when (val messagesState = uiState.messages) { is ConversationMessagesUiState.Loading -> { @@ -188,6 +196,8 @@ private fun ConversationScreenContent( modifier = modifier.padding(paddingValues = contentPadding), messages = messagesState.messages, listState = messagesListState, + onAttachmentClick = onAttachmentClick, + onExternalUriClick = onExternalUriClick, ) } } diff --git a/src/com/android/messaging/ui/conversation/v2/screen/ConversationScreenEffects.kt b/src/com/android/messaging/ui/conversation/v2/screen/ConversationScreenEffects.kt index 1a1dcd80..d56b6ed9 100644 --- a/src/com/android/messaging/ui/conversation/v2/screen/ConversationScreenEffects.kt +++ b/src/com/android/messaging/ui/conversation/v2/screen/ConversationScreenEffects.kt @@ -1,29 +1,48 @@ package com.android.messaging.ui.conversation.v2.screen +import android.content.ContentResolver import android.content.Context import android.content.Intent +import android.net.Uri +import android.view.View import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalView import androidx.core.net.toUri import com.android.messaging.R +import com.android.messaging.ui.UIIntents import com.android.messaging.ui.conversation.v2.screen.model.ConversationScreenEffect +import com.android.messaging.util.ContentType import com.android.messaging.util.UiUtils +import com.android.messaging.util.UriUtil +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext @Composable internal fun ConversationScreenEffects( screenModel: ConversationScreenModel, ) { val context = LocalContext.current + val hostView = LocalView.current - LaunchedEffect(screenModel, context) { + LaunchedEffect(screenModel, context, hostView) { screenModel.effects.collect { effect -> when (effect) { is ConversationScreenEffect.OpenAttachmentPreview -> { openAttachmentPreview( context = context, + hostView = hostView, contentUri = effect.contentUri, contentType = effect.contentType, + imageCollectionUri = effect.imageCollectionUri, + ) + } + + is ConversationScreenEffect.OpenExternalUri -> { + openExternalUri( + context = context, + uri = effect.uri, ) } @@ -35,19 +54,109 @@ internal fun ConversationScreenEffects( } } -private fun openAttachmentPreview( +private fun openExternalUri( context: Context, + uri: String, +) { + UIIntents.get().launchBrowserForUrl(context, uri) +} + +private suspend fun openAttachmentPreview( + context: Context, + hostView: View, contentUri: String, contentType: String, + imageCollectionUri: String?, ) { - val previewIntent = Intent(Intent.ACTION_VIEW).apply { - setDataAndType(contentUri.toUri(), contentType) - addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + val attachmentUri = contentUri.toUri() + + when { + ContentType.isImageType(contentType) -> { + val isOpenedInternally = openImageAttachmentPreview( + context = context, + hostView = hostView, + attachmentUri = attachmentUri, + imageCollectionUri = imageCollectionUri, + ) + if (!isOpenedInternally) { + openGenericAttachmentPreview( + context = context, + attachmentUri = attachmentUri, + contentType = contentType, + ) + } + } + + ContentType.isVCardType(contentType) -> { + UIIntents.get().launchVCardDetailActivity( + context, + normalizeAttachmentUriForIntent(attachmentUri = attachmentUri), + ) + } + + ContentType.isVideoType(contentType) -> { + UIIntents.get().launchFullScreenVideoViewer( + context, + normalizeAttachmentUriForIntent(attachmentUri = attachmentUri), + ) + } + + else -> { + openGenericAttachmentPreview( + context = context, + attachmentUri = normalizeAttachmentUriForIntent(attachmentUri = attachmentUri), + contentType = contentType, + ) + } } +} + +private fun openImageAttachmentPreview( + context: Context, + hostView: View, + attachmentUri: Uri, + imageCollectionUri: String?, +): Boolean { + val activity = UiUtils.getActivity(context) ?: return false + val imageCollection = imageCollectionUri?.toUri() ?: return false + + UIIntents.get().launchFullScreenPhotoViewer( + activity, + attachmentUri, + UiUtils.getMeasuredBoundsOnScreen(hostView), + imageCollection, + ) + return true +} + +private fun openGenericAttachmentPreview( + context: Context, + attachmentUri: Uri, + contentType: String, +) { runCatching { - context.startActivity(previewIntent) + Intent(Intent.ACTION_VIEW) + .apply { + setDataAndType(attachmentUri, contentType) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + .let(context::startActivity) }.onFailure { UiUtils.showToastAtBottom(R.string.activity_not_found_message) } } + +private suspend fun normalizeAttachmentUriForIntent( + attachmentUri: Uri, +): Uri { + return when { + attachmentUri.scheme != ContentResolver.SCHEME_FILE -> attachmentUri + + else -> { + withContext(context = Dispatchers.IO) { + UriUtil.persistContentToScratchSpace(attachmentUri) ?: attachmentUri + } + } + } +} diff --git a/src/com/android/messaging/ui/conversation/v2/screen/ConversationViewModel.kt b/src/com/android/messaging/ui/conversation/v2/screen/ConversationViewModel.kt index 4b809289..8a460ae0 100644 --- a/src/com/android/messaging/ui/conversation/v2/screen/ConversationViewModel.kt +++ b/src/com/android/messaging/ui/conversation/v2/screen/ConversationViewModel.kt @@ -4,6 +4,7 @@ import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.android.messaging.data.media.model.ConversationMediaItem +import com.android.messaging.datamodel.MessagingContentProvider import com.android.messaging.di.core.DefaultDispatcher import com.android.messaging.ui.conversation.v2.composer.delegate.ConversationDraftDelegate import com.android.messaging.ui.conversation.v2.composer.mapper.ConversationComposerUiStateMapper @@ -38,6 +39,13 @@ internal interface ConversationScreenModel { attachment: ConversationComposerAttachmentUiState.Resolved, ) + fun onMessageAttachmentClicked( + contentType: String, + contentUri: String, + ) + + fun onExternalUriClicked(uri: String) + fun onGalleryMediaConfirmed(mediaItems: List) fun onMessageTextChanged(text: String) fun onGalleryVisibilityChanged(isVisible: Boolean) @@ -186,11 +194,49 @@ internal class ConversationViewModel @Inject constructor( override fun onAttachmentClicked( attachment: ConversationComposerAttachmentUiState.Resolved, ) { + val conversationId = conversationIdFlow.value + + val imageCollectionUri = conversationId + ?.let(MessagingContentProvider::buildDraftImagesUri) + ?.toString() + viewModelScope.launch(defaultDispatcher) { _effects.emit( ConversationScreenEffect.OpenAttachmentPreview( contentType = attachment.contentType, contentUri = attachment.contentUri, + imageCollectionUri = imageCollectionUri, + ), + ) + } + } + + override fun onMessageAttachmentClicked( + contentType: String, + contentUri: String, + ) { + val conversationId = conversationIdFlow.value + + val imageCollectionUri = conversationId + ?.let(MessagingContentProvider::buildConversationImagesUri) + ?.toString() + + viewModelScope.launch(defaultDispatcher) { + _effects.emit( + ConversationScreenEffect.OpenAttachmentPreview( + contentType = contentType, + contentUri = contentUri, + imageCollectionUri = imageCollectionUri, + ), + ) + } + } + + override fun onExternalUriClicked(uri: String) { + viewModelScope.launch(defaultDispatcher) { + _effects.emit( + ConversationScreenEffect.OpenExternalUri( + uri = uri, ), ) } diff --git a/src/com/android/messaging/ui/conversation/v2/screen/model/ConversationScreenEffect.kt b/src/com/android/messaging/ui/conversation/v2/screen/model/ConversationScreenEffect.kt index 8a50bd6a..9e701019 100644 --- a/src/com/android/messaging/ui/conversation/v2/screen/model/ConversationScreenEffect.kt +++ b/src/com/android/messaging/ui/conversation/v2/screen/model/ConversationScreenEffect.kt @@ -1,8 +1,18 @@ package com.android.messaging.ui.conversation.v2.screen.model internal sealed interface ConversationScreenEffect { - data class OpenAttachmentPreview(val contentType: String, val contentUri: String) : - ConversationScreenEffect - data class ShowMessage(val messageResId: Int) : ConversationScreenEffect + data class OpenAttachmentPreview( + val contentType: String, + val contentUri: String, + val imageCollectionUri: String?, + ) : ConversationScreenEffect + + data class OpenExternalUri( + val uri: String, + ) : ConversationScreenEffect + + data class ShowMessage( + val messageResId: Int, + ) : ConversationScreenEffect } diff --git a/src/com/android/messaging/ui/conversation/v2/screen/model/ConversationScreenScaffoldUiState.kt b/src/com/android/messaging/ui/conversation/v2/screen/model/ConversationScreenScaffoldUiState.kt index 2cdc38a7..28dafb81 100644 --- a/src/com/android/messaging/ui/conversation/v2/screen/model/ConversationScreenScaffoldUiState.kt +++ b/src/com/android/messaging/ui/conversation/v2/screen/model/ConversationScreenScaffoldUiState.kt @@ -2,7 +2,7 @@ package com.android.messaging.ui.conversation.v2.screen.model import androidx.compose.runtime.Immutable import com.android.messaging.ui.conversation.v2.composer.model.ConversationComposerUiState -import com.android.messaging.ui.conversation.v2.messages.model.ConversationMessagesUiState +import com.android.messaging.ui.conversation.v2.messages.model.message.ConversationMessagesUiState import com.android.messaging.ui.conversation.v2.metadata.model.ConversationMetadataUiState @Immutable From 0ad23c7c57a708dfd348e7724a92b30f83331cf4 Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Tue, 14 Apr 2026 17:58:23 +0300 Subject: [PATCH 23/99] Expand debug conversation seed data --- app/src/debug/assets/seed_video.mp4 | Bin 0 -> 1904 bytes .../android/messaging/debug/TestDataSeeder.kt | 509 ++++++++++++++---- 2 files changed, 392 insertions(+), 117 deletions(-) create mode 100644 app/src/debug/assets/seed_video.mp4 diff --git a/app/src/debug/assets/seed_video.mp4 b/app/src/debug/assets/seed_video.mp4 new file mode 100644 index 0000000000000000000000000000000000000000..c915c8d610d940c1ceac61fef517e74fec53b843 GIT binary patch literal 1904 zcmah}U1%It6uz6-5@NJJMywFwR$HjjY-VPYZHyhvCX}X7r6B2p3e!6?cQez>>`d<5 zO?LZYk)nOj7r{dN&^M_qMG8LnB-ElJsP7j1p$4=fSbZoItXaP^yPK?`;9>5Z|L>f8 z&z)h6@v3g7VV1;<^)cdDmRR=8I2|_`>;E`Tl4ZtN6fXxpcn|g8hW|DzFnTuVKMKs7 z^y!Vr2T6VC;|Hz8BAl=5#k^*JPhba)YHy-Vbski=FpZo)^SnLM*BPV{8D%=R5QdlI z9JIIXJ$~Ca;wwopMC2c;tBAMd!u3qM5oGYJI}nPwMneN9>cwmurm^igh_r|bj~_*R z*(zF-Mbj{N6uJ$oJl=NZ?_I4Hco|W%1)mMIZm}m9z^~q{RQ@h($K)Bk3e$hhoIg=u zTZ7+Mzkd4qKlkoY^7d3@DLC~(YuMGQbI2nBbfR6E&Idp;#SAj`5Xr;X7Y&A}n}5RF zh(#RP2Ri=Y4)5xul0U}+|3818H-eg8p1D!8Q->Xd?^MS}4E8?SaU$zB z(yijA4>h zOwz<-7|;i*O5zIkzaJwC-F~!nORII|`$NBd`peC^PtNWCj^BLdk2^GOKFYmBNp6-W zxGGte#of|G*_aS;%oPzZ_R_IKheyYG<@hWjed)n5o21Q1RyDT_qcmz6)&z`!*6GyP z*viUEkv@PV618HY7RIPbG0<^@m?YI4T+~F= z%%p8JN>fI|G)p{|b|4!(tGkd6@HA^86>mrRwplcggcpy)hV&gmG?C#bQCpC|4# zl2|xMHMv$Yioy)iWMk6zGz4!cwhgo({8LFy+LpQBGlCQ6b;5kPUeHj`&j=%JUc!QeHO*YLtvXzC-ZX{jQCAlTigu0+%Ek~kF_ zx`!je|1Yco0)a|UOjU_kW;iU@Nt=*E-^Q#Q;fs`Z;W}<8GAa@(neS^wh6{mq;Y(6k za~6^m-nKDC>Y{7{+qNc*j_Ba8WSa)hJXw=oUEAXZwW$OhsghZMbmckg+az6@v`p=B9;O%)E(B%>EU|?7(=^#>+~65` z_6*z6TKjeN{@}?k9^QGs`r^SjUU{zgZlk_*_LZ-S?|gaq?p?O!&d#gO@t4i(Fbq~M&NSYBgRukT9xPlGdKTKT z>9*#b%(V)%$0`?sBOg|R^G63(T5FfjF_wG=^HAmDTZeY+e*u5SF2o0I2ucFrr8dNL T8{#q`B0waqwM%Ch`|() db.withTransaction { val participantIds = mutableListOf() @@ -85,7 +99,7 @@ fun clearSeededTestData(context: Context) { arrayOf("$TEST_PHONE_PREFIX%"), null, null, - null + null, )?.use { cursor -> while (cursor.moveToNext()) participantIds.add(cursor.getString(0)) } @@ -106,24 +120,42 @@ fun clearSeededTestData(context: Context) { pArgs, null, null, - null + null, )?.use { cursor -> while (cursor.moveToNext()) conversationIds.add(cursor.getString(0)) } if (conversationIds.isNotEmpty()) { + val conversationIdArgs = conversationIds.toTypedArray() + db.query( + DatabaseHelper.PARTS_TABLE, + arrayOf(PartColumns.CONTENT_URI), + "${PartColumns.CONVERSATION_ID} IN (${conversationIds.joinToString(",") { "?" }})" + + " AND ${PartColumns.CONTENT_URI} IS NOT NULL", + conversationIdArgs, + null, + null, + null, + )?.use { cursor -> + while (cursor.moveToNext()) { + cursor.getString(0)?.let { attachmentUri -> + seededAttachmentUris.add(attachmentUri) + } + } + } + // ON DELETE CASCADE handles messages and parts db.delete( DatabaseHelper.CONVERSATIONS_TABLE, "${ConversationColumns._ID} IN (${conversationIds.joinToString(",") { "?" }})", - conversationIds.toTypedArray() + conversationIdArgs, ) } db.delete( DatabaseHelper.PARTICIPANTS_TABLE, "${ParticipantColumns._ID} IN ($pPlaceholders)", - pArgs + pArgs, ) } @@ -131,6 +163,19 @@ fun clearSeededTestData(context: Context) { for (i in 1..3) { File(context.cacheDir, "seed_img_$i.jpg").delete() } + File(context.cacheDir, "seed_video.mp4").delete() + File(context.cacheDir, "seed_contact.vcf").delete() + deleteSeedScratchFile(fileId = SEED_IMAGE_1_FILE_ID, fileExtension = "jpg") + deleteSeedScratchFile(fileId = SEED_IMAGE_2_FILE_ID, fileExtension = "jpg") + deleteSeedScratchFile(fileId = SEED_IMAGE_3_FILE_ID, fileExtension = "jpg") + deleteSeedScratchFile(fileId = SEED_VCARD_FILE_ID, fileExtension = "vcf") + deleteSeedScratchFile(fileId = SEED_VIDEO_FILE_ID, fileExtension = "mp4") + deleteSeedScratchFile(fileId = SEED_IMAGE_1_FILE_ID) + deleteSeedScratchFile(fileId = SEED_IMAGE_2_FILE_ID) + deleteSeedScratchFile(fileId = SEED_IMAGE_3_FILE_ID) + deleteSeedScratchFile(fileId = SEED_VCARD_FILE_ID) + deleteSeedScratchFile(fileId = SEED_VIDEO_FILE_ID) + deleteSeededAttachmentScratchFiles(attachmentUris = seededAttachmentUris) MessagingContentProvider.notifyConversationListChanged() LogUtil.d(TAG, "Seeded test data cleared") @@ -138,31 +183,137 @@ fun clearSeededTestData(context: Context) { private fun buildTestImages(context: Context): List { val specs = listOf( - Triple("seed_img_1.jpg", Color.rgb(100, 149, 237), "Photo 1"), - Triple("seed_img_2.jpg", Color.rgb(144, 238, 144), "Photo 2"), - Triple("seed_img_3.jpg", Color.rgb(255, 160, 122), "Photo 3") + SeedImageSpec( + fileId = SEED_IMAGE_1_FILE_ID, + fileExtension = "jpg", + backgroundColor = Color.rgb(100, 149, 237), + label = "Photo 1", + ), + SeedImageSpec( + fileId = SEED_IMAGE_2_FILE_ID, + fileExtension = "jpg", + backgroundColor = Color.rgb(144, 238, 144), + label = "Photo 2", + ), + SeedImageSpec( + fileId = SEED_IMAGE_3_FILE_ID, + fileExtension = "jpg", + backgroundColor = Color.rgb(255, 160, 122), + label = "Photo 3", + ), ) - return specs.map { (filename, bgColor, label) -> - val file = File(context.cacheDir, filename) - if (!file.exists()) { - val bmp = createBitmap(400, 300) - val canvas = Canvas(bmp) - canvas.drawColor(bgColor) - val paint = Paint().apply { - color = Color.WHITE - textSize = 48f - isAntiAlias = true - isFakeBoldText = true - } - canvas.drawText(label, 130f, 165f, paint) - FileOutputStream(file).use { bmp.compress(Bitmap.CompressFormat.JPEG, 90, it) } - bmp.recycle() + return specs.map { spec -> + val imageUri = buildSeedScratchUri( + fileId = spec.fileId, + fileExtension = spec.fileExtension, + ) + val file = MediaScratchFileProvider.getFileFromUri(imageUri) + val bmp = createBitmap(400, 300) + val canvas = Canvas(bmp) + canvas.drawColor(spec.backgroundColor) + val paint = Paint().apply { + color = Color.WHITE + textSize = 48f + isAntiAlias = true + isFakeBoldText = true + } + file.parentFile?.mkdirs() + canvas.drawText(spec.label, 130f, 165f, paint) + FileOutputStream(file).use { outputStream -> + bmp.compress(Bitmap.CompressFormat.JPEG, 90, outputStream) + } + bmp.recycle() + imageUri.toString() + } +} + +private fun buildTestVCard(context: Context): String { + val vCardUri = buildSeedScratchUri( + fileId = SEED_VCARD_FILE_ID, + fileExtension = "vcf", + ) + val file = MediaScratchFileProvider.getFileFromUri(vCardUri) + file.parentFile?.mkdirs() + file.writeText( + """ + BEGIN:VCARD + VERSION:3.0 + FN:Sam Rivera + N:Rivera;Sam;;; + TEL;TYPE=CELL:+15550001111 + EMAIL:sam.rivera@example.com + END:VCARD + """.trimIndent(), + ) + + MediaScratchFileProvider.addUriToDisplayNameEntry(vCardUri, "Sam Rivera") + return vCardUri.toString() +} + +private fun buildTestVideo(context: Context): String { + val videoUri = buildSeedScratchUri( + fileId = SEED_VIDEO_FILE_ID, + fileExtension = "mp4", + ) + val file = MediaScratchFileProvider.getFileFromUri(videoUri) + file.parentFile?.mkdirs() + context.assets.open("seed_video.mp4").use { inputStream -> + FileOutputStream(file).use { outputStream -> + inputStream.copyTo(outputStream) + } + } + + MediaScratchFileProvider.addUriToDisplayNameEntry(videoUri, "seed_video.mp4") + return videoUri.toString() +} + +private fun buildSeedScratchUri( + fileId: String, + fileExtension: String? = null, +): Uri { + val uriBuilder = MediaScratchFileProvider.getUriBuilder() + .appendPath(fileId) + if (!fileExtension.isNullOrBlank()) { + uriBuilder.appendQueryParameter( + MEDIA_SCRATCH_FILE_EXTENSION_QUERY_PARAMETER, + fileExtension, + ) + } + return uriBuilder.build() +} + +private fun deleteSeedScratchFile( + fileId: String, + fileExtension: String? = null, +) { + val seedScratchUri = buildSeedScratchUri( + fileId = fileId, + fileExtension = fileExtension, + ) + MediaScratchFileProvider.getFileFromUri(seedScratchUri).delete() +} + +private fun deleteSeededAttachmentScratchFiles( + attachmentUris: Set, +) { + attachmentUris.forEach { attachmentUri -> + val uri = attachmentUri.toUri() + if (!MediaScratchFileProvider.isMediaScratchSpaceUri(uri)) { + return@forEach } - Uri.fromFile(file).toString() + + MediaScratchFileProvider.getFileFromUri(uri).delete() } } +private data class SeedImageSpec( + val fileId: String, + val fileExtension: String, + val backgroundColor: Int, + val label: String, +) + private fun findSelfParticipantId(db: DatabaseWrapper): String? = db.query( DatabaseHelper.PARTICIPANTS_TABLE, arrayOf(ParticipantColumns._ID), @@ -171,7 +322,7 @@ private fun findSelfParticipantId(db: DatabaseWrapper): String? = db.query( null, null, "${ParticipantColumns._ID} ASC", - "1" + "1", )?.use { cursor -> if (cursor.moveToFirst()) cursor.getString(0) else null } @@ -193,7 +344,7 @@ private fun upsertParticipant( put(ParticipantColumns.FULL_NAME, fullName) put(ParticipantColumns.FIRST_NAME, firstName) }, - SQLiteDatabase.CONFLICT_IGNORE + SQLiteDatabase.CONFLICT_IGNORE, ) return db @@ -204,7 +355,7 @@ private fun upsertParticipant( arrayOf(phone, ParticipantData.OTHER_THAN_SELF_SUB_ID.toString()), null, null, - null + null, ) ?.use { cursor -> cursor.moveToFirst() @@ -239,7 +390,7 @@ private fun createConversation( ) { put(ConversationColumns.PREVIEW_CONTENT_TYPE, previewContentType) } - } + }, ) for (participantId in participantIds) { db.insert( @@ -248,7 +399,7 @@ private fun createConversation( ContentValues().apply { put(ConversationParticipantsColumns.CONVERSATION_ID, conversationId) put(ConversationParticipantsColumns.PARTICIPANT_ID, participantId) - } + }, ) } return conversationId @@ -269,7 +420,7 @@ private fun insertTextMessage( ): Long { val messageId = insertMessageRow( db, conversationId, senderId, selfId, - status, protocol, timestamp, seen, read, mmsSubject + status, protocol, timestamp, seen, read, mmsSubject, ) db.insert( DatabaseHelper.PARTS_TABLE, @@ -279,7 +430,7 @@ private fun insertTextMessage( put(PartColumns.CONVERSATION_ID, conversationId) put(PartColumns.TEXT, text) put(PartColumns.CONTENT_TYPE, ContentType.TEXT_PLAIN) - } + }, ) return messageId } @@ -295,23 +446,20 @@ private fun insertImageMessage( seen: Boolean = true, read: Boolean = true, ): Long { - val messageId = insertMessageRow( - db, conversationId, senderId, selfId, - status, MessageData.PROTOCOL_MMS, timestamp, seen, read, mmsSubject = null - ) - db.insert( - DatabaseHelper.PARTS_TABLE, - null, - ContentValues().apply { - put(PartColumns.MESSAGE_ID, messageId) - put(PartColumns.CONVERSATION_ID, conversationId) - put(PartColumns.CONTENT_TYPE, ContentType.IMAGE_JPEG) - put(PartColumns.CONTENT_URI, imageUri) - put(PartColumns.WIDTH, 400) - put(PartColumns.HEIGHT, 300) - } + return insertAttachmentMessage( + db = db, + conversationId = conversationId, + senderId = senderId, + selfId = selfId, + contentType = ContentType.IMAGE_JPEG, + attachmentUri = imageUri, + status = status, + timestamp = timestamp, + width = 400, + height = 300, + seen = seen, + read = read, ) - return messageId } private fun insertMixedMessage( @@ -326,9 +474,19 @@ private fun insertMixedMessage( seen: Boolean = true, read: Boolean = true, ): Long { - val messageId = insertMessageRow( - db, conversationId, senderId, selfId, - status, MessageData.PROTOCOL_MMS, timestamp, seen, read, mmsSubject = null + val messageId = insertAttachmentMessage( + db = db, + conversationId = conversationId, + senderId = senderId, + selfId = selfId, + contentType = ContentType.IMAGE_JPEG, + attachmentUri = imageUri, + status = status, + timestamp = timestamp, + width = 400, + height = 300, + seen = seen, + read = read, ) db.insert( DatabaseHelper.PARTS_TABLE, @@ -336,11 +494,38 @@ private fun insertMixedMessage( ContentValues().apply { put(PartColumns.MESSAGE_ID, messageId) put(PartColumns.CONVERSATION_ID, conversationId) - put(PartColumns.CONTENT_TYPE, ContentType.IMAGE_JPEG) - put(PartColumns.CONTENT_URI, imageUri) - put(PartColumns.WIDTH, 400) - put(PartColumns.HEIGHT, 300) - } + put(PartColumns.TEXT, text) + put(PartColumns.CONTENT_TYPE, ContentType.TEXT_PLAIN) + }, + ) + return messageId +} + +private fun insertAttachmentMessage( + db: DatabaseWrapper, + conversationId: Long, + senderId: String, + selfId: String, + contentType: String, + attachmentUri: String, + status: Int, + timestamp: Long, + width: Int = 0, + height: Int = 0, + seen: Boolean = true, + read: Boolean = true, +): Long { + val messageId = insertMessageRow( + db = db, + conversationId = conversationId, + senderId = senderId, + selfId = selfId, + status = status, + protocol = MessageData.PROTOCOL_MMS, + timestamp = timestamp, + seen = seen, + read = read, + mmsSubject = null, ) db.insert( DatabaseHelper.PARTS_TABLE, @@ -348,13 +533,67 @@ private fun insertMixedMessage( ContentValues().apply { put(PartColumns.MESSAGE_ID, messageId) put(PartColumns.CONVERSATION_ID, conversationId) - put(PartColumns.TEXT, text) - put(PartColumns.CONTENT_TYPE, ContentType.TEXT_PLAIN) - } + put(PartColumns.CONTENT_TYPE, contentType) + put(PartColumns.CONTENT_URI, attachmentUri) + put(PartColumns.WIDTH, width) + put(PartColumns.HEIGHT, height) + }, ) return messageId } +private fun insertVCardMessage( + db: DatabaseWrapper, + conversationId: Long, + senderId: String, + selfId: String, + vCardUri: String, + status: Int, + timestamp: Long, + seen: Boolean = true, + read: Boolean = true, +): Long { + return insertAttachmentMessage( + db = db, + conversationId = conversationId, + senderId = senderId, + selfId = selfId, + contentType = ContentType.TEXT_VCARD, + attachmentUri = vCardUri, + status = status, + timestamp = timestamp, + seen = seen, + read = read, + ) +} + +private fun insertVideoMessage( + db: DatabaseWrapper, + conversationId: Long, + senderId: String, + selfId: String, + videoUri: String, + status: Int, + timestamp: Long, + seen: Boolean = true, + read: Boolean = true, +): Long { + return insertAttachmentMessage( + db = db, + conversationId = conversationId, + senderId = senderId, + selfId = selfId, + contentType = ContentType.VIDEO_MP4, + attachmentUri = videoUri, + status = status, + timestamp = timestamp, + width = 400, + height = 300, + seen = seen, + read = read, + ) +} + private fun insertMessageRow( db: DatabaseWrapper, conversationId: Long, @@ -380,7 +619,7 @@ private fun insertMessageRow( put(MessageColumns.SEEN, if (seen) 1 else 0) put(MessageColumns.READ, if (read) 1 else 0) if (mmsSubject != null) put(MessageColumns.MMS_SUBJECT, mmsSubject) - } + }, ) private fun finalizeConversation( @@ -406,7 +645,7 @@ private fun finalizeConversation( } }, "${ConversationColumns._ID} = ?", - arrayOf(conversationId.toString()) + arrayOf(conversationId.toString()), ) } @@ -437,7 +676,7 @@ private fun seedScenarioA(db: DatabaseWrapper, selfId: String, aliceId: String, "Will do", "See you Saturday!", "Looking forward to it", - "Don't forget to bring the book" + "Don't forget to bring the book", ) var latestMsgId = 0L @@ -462,7 +701,7 @@ private fun seedScenarioA(db: DatabaseWrapper, selfId: String, aliceId: String, texts[i % texts.size], status, MessageData.PROTOCOL_SMS, - msgTime + msgTime, ) latestTime = msgTime } @@ -484,7 +723,7 @@ private fun seedScenarioB(db: DatabaseWrapper, selfId: String, bobId: String, no Triple( "Can you send the updated version?", false, - MessageData.BUGLE_STATUS_OUTGOING_COMPLETE + MessageData.BUGLE_STATUS_OUTGOING_COMPLETE, ), Triple("Sure, give me a minute", true, MessageData.BUGLE_STATUS_INCOMING_COMPLETE), Triple("Here you go", true, MessageData.BUGLE_STATUS_INCOMING_COMPLETE), @@ -497,7 +736,7 @@ private fun seedScenarioB(db: DatabaseWrapper, selfId: String, bobId: String, no Triple("Great, see you then", true, MessageData.BUGLE_STATUS_INCOMING_COMPLETE), Triple("Perfect", false, MessageData.BUGLE_STATUS_OUTGOING_COMPLETE), Triple("Don't be late!", true, MessageData.BUGLE_STATUS_INCOMING_COMPLETE), - Triple("Never", false, MessageData.BUGLE_STATUS_OUTGOING_AWAITING_RETRY) + Triple("Never", false, MessageData.BUGLE_STATUS_OUTGOING_AWAITING_RETRY), ) var latestMsgId = 0L @@ -514,7 +753,7 @@ private fun seedScenarioB(db: DatabaseWrapper, selfId: String, bobId: String, no text, status, MessageData.PROTOCOL_SMS, - msgTime + msgTime, ) latestTime = msgTime } @@ -539,7 +778,7 @@ private fun seedScenarioC( "Team Chat", selfId, listOf(carolId, daveId, eveId), - baseTime + baseTime, ) val senders = listOf(carolId, daveId, eveId, selfId) @@ -551,7 +790,7 @@ private fun seedScenarioC( "Agreed", "Anyone need a ride?", "I'm good, thanks", "I could use one actually", "I got you Carol", "Thanks Dave!", "Ok see everyone at 3", "See you there!", "Don't forget it's at the usual place", "Got it", "See you all soon!", - "This is going to be fun", "Definitely", "On my way!" + "This is going to be fun", "Definitely", "On my way!", ) var latestMsgId = 0L @@ -572,7 +811,7 @@ private fun seedScenarioC( texts[i], status, MessageData.PROTOCOL_MMS, - msgTime + msgTime, ) latestTime = msgTime } @@ -597,7 +836,7 @@ private fun seedScenarioD(db: DatabaseWrapper, selfId: String, frankId: String, Pair("Bring sunscreen, it'll be hot", false), Pair("Good call", true), Pair("See you Saturday!", false), - Pair("Can't wait!", true) + Pair("Can't wait!", true), ) var latestMsgId = 0L @@ -613,7 +852,7 @@ private fun seedScenarioD(db: DatabaseWrapper, selfId: String, frankId: String, } latestMsgId = insertTextMessage( db, convId, senderId, selfId, - text, status, MessageData.PROTOCOL_MMS, msgTime, mmsSubject = "Weekend plans" + text, status, MessageData.PROTOCOL_MMS, msgTime, mmsSubject = "Weekend plans", ) latestTime = msgTime } @@ -646,7 +885,7 @@ private fun seedScenarioE(db: DatabaseWrapper, selfId: String, graceId: String, latestMsgId = insertTextMessage( db, convId, senderId, selfId, latestText, status, MessageData.PROTOCOL_SMS, msgTime, - seen = !isUnread, read = !isUnread + seen = !isUnread, read = !isUnread, ) } @@ -655,7 +894,7 @@ private fun seedScenarioE(db: DatabaseWrapper, selfId: String, graceId: String, convId, latestMsgId, baseTime + totalMessages * 2 * MINUTES, - latestText + latestText, ) } @@ -671,7 +910,7 @@ private fun seedScenarioF(db: DatabaseWrapper, selfId: String, henryId: String, "I need to show you something", "It's important", "Please reply when you get a chance", - "I'll be online for the next hour" + "I'll be online for the next hour", ) var latestMsgId = 0L @@ -681,7 +920,7 @@ private fun seedScenarioF(db: DatabaseWrapper, selfId: String, henryId: String, latestMsgId = insertTextMessage( db, convId, henryId, selfId, text, MessageData.BUGLE_STATUS_INCOMING_COMPLETE, MessageData.PROTOCOL_SMS, msgTime, - seen = false, read = false + seen = false, read = false, ) latestTime = msgTime } @@ -711,7 +950,7 @@ private fun seedScenarioG( listOf(irisId), baseTime, previewUri = img1, - previewContentType = ContentType.IMAGE_JPEG + previewContentType = ContentType.IMAGE_JPEG, ) data class Msg( @@ -731,7 +970,7 @@ private fun seedScenarioG( "mixed", text = "Here's another one from the same day", imageUri = img2, - isIncoming = true + isIncoming = true, ), Msg("text", text = "These are stunning", isIncoming = false), Msg("image", imageUri = img3, isIncoming = false), @@ -745,8 +984,8 @@ private fun seedScenarioG( "mixed", text = "Shot this from my window this morning", imageUri = img1, - isIncoming = false - ) + isIncoming = false, + ), ) var latestMsgId = 0L @@ -767,7 +1006,7 @@ private fun seedScenarioG( selfId, m.imageUri, status, - msgTime + msgTime, ) "mixed" -> insertMixedMessage( @@ -778,7 +1017,7 @@ private fun seedScenarioG( m.text, m.imageUri, status, - msgTime + msgTime, ) else -> insertTextMessage( @@ -789,7 +1028,7 @@ private fun seedScenarioG( m.text, status, MessageData.PROTOCOL_MMS, - msgTime + msgTime, ) } latestTime = msgTime @@ -802,7 +1041,7 @@ private fun seedScenarioG( latestTime, "Shot this from my window this morning", previewUri = img1, - previewContentType = ContentType.IMAGE_JPEG + previewContentType = ContentType.IMAGE_JPEG, ) } @@ -815,6 +1054,8 @@ private fun seedScenarioH( jackId: String, carolId: String, images: List, + videoUri: String, + vCardUri: String, now: Long, ) { val img1 = images[0] @@ -829,32 +1070,42 @@ private fun seedScenarioH( listOf(jackId, carolId), baseTime, previewUri = img2, - previewContentType = ContentType.IMAGE_JPEG + previewContentType = ContentType.IMAGE_JPEG, ) data class Msg( val type: String, val text: String = "", - val imageUri: String = "", + val attachmentUri: String = "", val senderId: String, ) val messages = listOf( Msg("text", text = "Dropping some pics from last night", senderId = jackId), - Msg("image", imageUri = img1, senderId = jackId), - Msg("image", imageUri = img3, senderId = jackId), + Msg("image", attachmentUri = img1, senderId = jackId), + Msg("image", attachmentUri = img3, senderId = jackId), Msg("text", text = "The lighting was perfect", senderId = jackId), Msg("text", text = "These are great Jack!", senderId = carolId), - Msg("image", imageUri = img2, senderId = carolId), + Msg("image", attachmentUri = img2, senderId = carolId), Msg("text", text = "I got a few too", senderId = carolId), Msg("text", text = "Love that shot Carol", senderId = selfId), - Msg("mixed", text = "Here's mine from the same spot", imageUri = img3, senderId = selfId), + Msg( + "mixed", + text = "Here's mine from the same spot", + attachmentUri = img3, + senderId = selfId, + ), Msg("text", text = "We all had the same idea haha", senderId = jackId), - Msg("image", imageUri = img1, senderId = carolId), + Msg("image", attachmentUri = img1, senderId = carolId), + Msg("text", text = TEST_YOUTUBE_VIDEO_URL, senderId = carolId), + Msg("text", text = "The clip version is even better", senderId = jackId), + Msg("video", attachmentUri = videoUri, senderId = carolId), + Msg("text", text = "Send me the photographer contact too", senderId = selfId), + Msg("vcard", attachmentUri = vCardUri, senderId = carolId), Msg("text", text = "One more", senderId = carolId), Msg("text", text = "We need to do this again soon", senderId = selfId), Msg("text", text = "+1", senderId = jackId), - Msg("text", text = "Same time next week?", senderId = carolId) + Msg("text", text = "Same time next week?", senderId = carolId), ) var latestMsgId = 0L @@ -872,9 +1123,9 @@ private fun seedScenarioH( convId, m.senderId, selfId, - m.imageUri, + m.attachmentUri, status, - msgTime + msgTime, ) "mixed" -> insertMixedMessage( @@ -883,9 +1134,29 @@ private fun seedScenarioH( m.senderId, selfId, m.text, - m.imageUri, + m.attachmentUri, status, - msgTime + msgTime, + ) + + "vcard" -> insertVCardMessage( + db = db, + conversationId = convId, + senderId = m.senderId, + selfId = selfId, + vCardUri = m.attachmentUri, + status = status, + timestamp = msgTime, + ) + + "video" -> insertVideoMessage( + db = db, + conversationId = convId, + senderId = m.senderId, + selfId = selfId, + videoUri = m.attachmentUri, + status = status, + timestamp = msgTime, ) else -> insertTextMessage( @@ -896,7 +1167,7 @@ private fun seedScenarioH( m.text, status, MessageData.PROTOCOL_MMS, - msgTime + msgTime, ) } latestTime = msgTime @@ -909,7 +1180,7 @@ private fun seedScenarioH( latestTime, "Same time next week?", previewUri = img2, - previewContentType = ContentType.IMAGE_JPEG + previewContentType = ContentType.IMAGE_JPEG, ) } @@ -930,92 +1201,96 @@ private fun seedScenarioI( name = "Clustering Test Cases", selfId = selfId, participantIds = listOf(carolId, daveId, eveId), - sortTimestamp = baseTime + sortTimestamp = baseTime, ) - data class ClusterTestMessage(val text: String, val senderId: String, val offsetMillis: Long) + data class ClusterTestMessage( + val text: String, + val senderId: String, + val offsetMillis: Long, + ) val messages = listOf( ClusterTestMessage( text = "Standalone incoming", senderId = carolId, - offsetMillis = 0L + offsetMillis = 0L, ), ClusterTestMessage( text = "Pair top", senderId = carolId, - offsetMillis = 2 * MINUTES + offsetMillis = 2 * MINUTES, ), ClusterTestMessage( text = "Pair bottom", senderId = carolId, - offsetMillis = 2 * MINUTES + 30_000L + offsetMillis = 2 * MINUTES + 30_000L, ), ClusterTestMessage( text = "Triplet top", senderId = daveId, - offsetMillis = 5 * MINUTES + offsetMillis = 5 * MINUTES, ), ClusterTestMessage( text = "Triplet middle", senderId = daveId, - offsetMillis = 5 * MINUTES + 20_000L + offsetMillis = 5 * MINUTES + 20_000L, ), ClusterTestMessage( text = "Triplet bottom", senderId = daveId, - offsetMillis = 5 * MINUTES + 40_000L + offsetMillis = 5 * MINUTES + 40_000L, ), ClusterTestMessage( text = "Quartet top", senderId = eveId, - offsetMillis = 8 * MINUTES + offsetMillis = 8 * MINUTES, ), ClusterTestMessage( text = "Quartet middle 1", senderId = eveId, - offsetMillis = 8 * MINUTES + 20_000L + offsetMillis = 8 * MINUTES + 20_000L, ), ClusterTestMessage( text = "Quartet middle 2", senderId = eveId, - offsetMillis = 8 * MINUTES + 40_000L + offsetMillis = 8 * MINUTES + 40_000L, ), ClusterTestMessage( text = "Quartet bottom", senderId = eveId, - offsetMillis = 9 * MINUTES + offsetMillis = 9 * MINUTES, ), ClusterTestMessage( text = "Same sender after gap", senderId = daveId, - offsetMillis = 12 * MINUTES + offsetMillis = 12 * MINUTES, ), ClusterTestMessage( text = "Gap break still standalone", senderId = daveId, - offsetMillis = 13 * MINUTES + 40_000L + offsetMillis = 13 * MINUTES + 40_000L, ), ClusterTestMessage( text = "Different sender break", senderId = carolId, - offsetMillis = 16 * MINUTES + offsetMillis = 16 * MINUTES, ), ClusterTestMessage( text = "Outgoing standalone", senderId = selfId, - offsetMillis = 16 * MINUTES + 20_000L + offsetMillis = 16 * MINUTES + 20_000L, ), ClusterTestMessage( text = "Outgoing pair top", senderId = selfId, - offsetMillis = 19 * MINUTES + offsetMillis = 19 * MINUTES, ), ClusterTestMessage( text = "Outgoing pair bottom", senderId = selfId, - offsetMillis = 19 * MINUTES + 20_000L - ) + offsetMillis = 19 * MINUTES + 20_000L, + ), ) var latestMessageId = 0L @@ -1039,7 +1314,7 @@ private fun seedScenarioI( text = message.text, status = status, protocol = MessageData.PROTOCOL_MMS, - timestamp = timestamp + timestamp = timestamp, ) latestTimestamp = timestamp } @@ -1049,6 +1324,6 @@ private fun seedScenarioI( conversationId = conversationId, latestMessageId = latestMessageId, latestTimestamp = latestTimestamp, - snippetText = latestText + snippetText = latestText, ) } From 27bb038e0f7203b703037767a127084e57177e1f Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Tue, 14 Apr 2026 17:58:34 +0300 Subject: [PATCH 24/99] Add MMS subject sanitizer --- .../messaging/sms/MmsSubjectSanitizer.kt | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 src/com/android/messaging/sms/MmsSubjectSanitizer.kt diff --git a/src/com/android/messaging/sms/MmsSubjectSanitizer.kt b/src/com/android/messaging/sms/MmsSubjectSanitizer.kt new file mode 100644 index 00000000..23d709c1 --- /dev/null +++ b/src/com/android/messaging/sms/MmsSubjectSanitizer.kt @@ -0,0 +1,27 @@ +package com.android.messaging.sms + +import android.content.res.Resources +import com.android.messaging.R + +internal fun cleanseMmsSubject( + resources: Resources, + subject: String?, +): String? { + return cleanseMmsSubject( + subject = subject, + emptySubjectStrings = resources.getStringArray(R.array.empty_subject_strings), + ) +} + +internal fun cleanseMmsSubject( + subject: String?, + emptySubjectStrings: Array, +): String? { + return subject + ?.takeIf(String::isNotEmpty) + ?.takeUnless { candidateSubject -> + emptySubjectStrings.any { emptySubjectString -> + candidateSubject.equals(other = emptySubjectString, ignoreCase = true) + } + } +} From 6f6f31ddc013a29be536e80b96a9009ead0fefc5 Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Tue, 14 Apr 2026 18:11:40 +0300 Subject: [PATCH 25/99] Fix invalid ktlint rules --- .editorconfig | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.editorconfig b/.editorconfig index f2003881..05374e76 100644 --- a/.editorconfig +++ b/.editorconfig @@ -16,7 +16,9 @@ ij_kotlin_name_count_to_use_star_import_for_members = 2147483647 ij_kotlin_packages_to_use_import_on_demand = unset ij_kotlin_line_break_after_multiline_when_entry = false ktlint_code_style = android_studio +ktlint_class_signature_rule_force_multiline_when_parameter_count_greater_or_equal_than = 1 ktlint_function_naming_ignore_when_annotated_with = Composable +ktlint_standard_filename = disabled ktlint_standard_function-expression-body = disabled ktlint_standard_function-signature = disabled ktlint_standard_trailing-comma-on-call-site = disabled From a9abb6e08bfb1447fdf968c06c65d664c13b70b0 Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Tue, 14 Apr 2026 20:36:54 +0300 Subject: [PATCH 26/99] Extract ConversationMessageDataDraftMapper and reuse it in draft repository --- .../ConversationMessageDataDraftMapper.kt | 74 +++++++++++++++++++ .../ConversationDraftsRepository.kt | 64 ++++------------ .../conversation/ConversationBindsModule.kt | 8 ++ 3 files changed, 98 insertions(+), 48 deletions(-) create mode 100644 src/com/android/messaging/data/conversation/mapper/ConversationMessageDataDraftMapper.kt diff --git a/src/com/android/messaging/data/conversation/mapper/ConversationMessageDataDraftMapper.kt b/src/com/android/messaging/data/conversation/mapper/ConversationMessageDataDraftMapper.kt new file mode 100644 index 00000000..7a58ddb2 --- /dev/null +++ b/src/com/android/messaging/data/conversation/mapper/ConversationMessageDataDraftMapper.kt @@ -0,0 +1,74 @@ +package com.android.messaging.data.conversation.mapper + +import com.android.messaging.data.conversation.model.draft.ConversationDraft +import com.android.messaging.data.conversation.model.draft.ConversationDraftAttachment +import com.android.messaging.datamodel.data.MessageData +import com.android.messaging.datamodel.data.MessagePartData +import com.android.messaging.util.LogUtil +import javax.inject.Inject + +internal interface ConversationMessageDataDraftMapper { + fun map( + messageData: MessageData, + fallbackSelfParticipantId: String? = null, + ): ConversationDraft +} + +internal class ConversationMessageDataDraftMapperImpl @Inject constructor() : + ConversationMessageDataDraftMapper { + + override fun map( + messageData: MessageData, + fallbackSelfParticipantId: String?, + ): ConversationDraft { + return ConversationDraft( + messageText = messageData.messageText, + subjectText = messageData.mmsSubject.orEmpty(), + selfParticipantId = messageData + .selfId + ?.takeIf { it.isNotBlank() } + ?: fallbackSelfParticipantId.orEmpty(), + attachments = messageData.parts + .asSequence() + .filter { it.isAttachment } + .mapNotNull(::createDraftAttachmentOrNull) + .toList(), + ) + } + + private fun createDraftAttachmentOrNull( + part: MessagePartData, + ): ConversationDraftAttachment? { + val contentType = part.contentType?.takeIf { it.isNotBlank() } + val contentUri = part.contentUri?.toString()?.takeIf { it.isNotBlank() } + + return when { + contentType != null && contentUri != null -> { + ConversationDraftAttachment( + contentType = contentType, + contentUri = contentUri, + captionText = part.text.orEmpty(), + width = normalizePartDimension(size = part.width), + height = normalizePartDimension(size = part.height), + ) + } + + else -> { + LogUtil.w( + TAG, + "Dropping draft attachment with blank contentType or contentUri", + ) + + null + } + } + } + + private fun normalizePartDimension(size: Int): Int? { + return size.takeIf { it != MessagePartData.UNSPECIFIED_SIZE } + } + + private companion object { + private const val TAG = "ConversationMsgDataDraftMapper" + } +} diff --git a/src/com/android/messaging/data/conversation/repository/ConversationDraftsRepository.kt b/src/com/android/messaging/data/conversation/repository/ConversationDraftsRepository.kt index dacf63ec..fe6d0cb0 100644 --- a/src/com/android/messaging/data/conversation/repository/ConversationDraftsRepository.kt +++ b/src/com/android/messaging/data/conversation/repository/ConversationDraftsRepository.kt @@ -4,11 +4,10 @@ import android.content.ContentResolver import android.database.ContentObserver import android.net.Uri import com.android.messaging.data.conversation.mapper.ConversationDraftMessageDataMapper +import com.android.messaging.data.conversation.mapper.ConversationMessageDataDraftMapper import com.android.messaging.data.conversation.model.draft.ConversationDraft -import com.android.messaging.data.conversation.model.draft.ConversationDraftAttachment import com.android.messaging.datamodel.MessagingContentProvider import com.android.messaging.datamodel.data.MessageData -import com.android.messaging.datamodel.data.MessagePartData import com.android.messaging.di.core.IoDispatcher import com.android.messaging.util.LogUtil import javax.inject.Inject @@ -34,6 +33,7 @@ internal interface ConversationDraftsRepository { internal class ConversationDraftsRepositoryImpl @Inject constructor( private val contentResolver: ContentResolver, private val conversationDraftMessageDataMapper: ConversationDraftMessageDataMapper, + private val conversationMessageDataDraftMapper: ConversationMessageDataDraftMapper, private val conversationDraftStore: ConversationDraftStore, private val conversationMetadataNotifier: ConversationMetadataNotifier, @param:IoDispatcher @@ -120,25 +120,20 @@ internal class ConversationDraftsRepositoryImpl @Inject constructor( conversation: ConversationDraftConversation, draftMessage: MessageData?, ): ConversationDraft { - val attachments = draftMessage - ?.parts - ?.asSequence() - ?.filter { part -> part.isAttachment } - ?.mapNotNull(::createDraftAttachmentOrNull) - ?.toList() - ?: emptyList() - - val selfParticipantId = draftMessage - ?.selfId - ?.takeIf { selfParticipantId -> selfParticipantId.isNotBlank() } - ?: conversation.selfParticipantId - - return ConversationDraft( - messageText = draftMessage?.messageText.orEmpty(), - subjectText = draftMessage?.mmsSubject.orEmpty(), - selfParticipantId = selfParticipantId, - attachments = attachments, - ) + return when (draftMessage) { + null -> { + ConversationDraft( + selfParticipantId = conversation.selfParticipantId, + ) + } + + else -> { + conversationMessageDataDraftMapper.map( + messageData = draftMessage, + fallbackSelfParticipantId = conversation.selfParticipantId, + ) + } + } } private fun bindDraftParticipantsIfNeeded( @@ -171,33 +166,6 @@ internal class ConversationDraftsRepositoryImpl @Inject constructor( return message } - private fun createDraftAttachmentOrNull(part: MessagePartData): ConversationDraftAttachment? { - val contentType = part.contentType?.takeIf { it.isNotBlank() } - val contentUri = part.contentUri?.toString()?.takeIf { it.isNotBlank() } - - return when { - contentType != null && contentUri != null -> { - ConversationDraftAttachment( - contentType = contentType, - contentUri = contentUri, - captionText = part.text.orEmpty(), - width = normalizePartDimension(size = part.width), - height = normalizePartDimension(size = part.height), - ) - } - - else -> { - LogUtil.w(TAG, "Dropping draft attachment with blank contentType or contentUri") - - null - } - } - } - - private fun normalizePartDimension(size: Int): Int? { - return size.takeIf { it != MessagePartData.UNSPECIFIED_SIZE } - } - private companion object { private const val TAG = "ConversationDraftsRepository" } diff --git a/src/com/android/messaging/di/conversation/ConversationBindsModule.kt b/src/com/android/messaging/di/conversation/ConversationBindsModule.kt index 8de30e74..3798874e 100644 --- a/src/com/android/messaging/di/conversation/ConversationBindsModule.kt +++ b/src/com/android/messaging/di/conversation/ConversationBindsModule.kt @@ -2,6 +2,8 @@ package com.android.messaging.di.conversation import com.android.messaging.data.conversation.mapper.ConversationDraftMessageDataMapper import com.android.messaging.data.conversation.mapper.ConversationDraftMessageDataMapperImpl +import com.android.messaging.data.conversation.mapper.ConversationMessageDataDraftMapper +import com.android.messaging.data.conversation.mapper.ConversationMessageDataDraftMapperImpl import com.android.messaging.data.conversation.repository.ConversationDraftStore import com.android.messaging.data.conversation.repository.ConversationDraftStoreImpl import com.android.messaging.data.conversation.repository.ConversationDraftsRepository @@ -38,6 +40,12 @@ internal abstract class ConversationBindsModule { impl: ConversationDraftMessageDataMapperImpl, ): ConversationDraftMessageDataMapper + @Binds + @Reusable + abstract fun bindConversationMessageDataDraftMapper( + impl: ConversationMessageDataDraftMapperImpl, + ): ConversationMessageDataDraftMapper + @Binds @Reusable abstract fun bindConversationDraftStore( From cabb9187870769545a4cf2341b728abb2c6fb009 Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Tue, 14 Apr 2026 20:37:13 +0300 Subject: [PATCH 27/99] Handle v2 conversation launch requests and startup draft seeding --- .../conversation/v2/ConversationActivity.kt | 77 +++++++++++++++++-- .../delegate/ConversationDraftDelegate.kt | 37 +++++++++ .../delegate/ConversationDraftEditorState.kt | 22 ++++++ .../v2/screen/ConversationScreen.kt | 26 +++++-- .../v2/screen/ConversationScreenEffects.kt | 39 +++++++--- .../v2/screen/ConversationViewModel.kt | 57 +++++++++++++- .../screen/model/ConversationLaunchRequest.kt | 13 ++++ 7 files changed, 248 insertions(+), 23 deletions(-) create mode 100644 src/com/android/messaging/ui/conversation/v2/screen/model/ConversationLaunchRequest.kt diff --git a/src/com/android/messaging/ui/conversation/v2/ConversationActivity.kt b/src/com/android/messaging/ui/conversation/v2/ConversationActivity.kt index 71e04e54..6ec9d524 100644 --- a/src/com/android/messaging/ui/conversation/v2/ConversationActivity.kt +++ b/src/com/android/messaging/ui/conversation/v2/ConversationActivity.kt @@ -2,34 +2,43 @@ package com.android.messaging.ui.conversation.v2 import android.content.Intent import android.os.Bundle +import android.text.TextUtils import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue +import com.android.messaging.datamodel.data.MessageData import com.android.messaging.ui.UIIntents import com.android.messaging.ui.conversation.v2.screen.ConversationScreen +import com.android.messaging.ui.conversation.v2.screen.model.ConversationLaunchRequest +import com.android.messaging.ui.conversationlist.ConversationListActivity import com.android.messaging.ui.core.AppTheme import dagger.hilt.android.AndroidEntryPoint @AndroidEntryPoint internal class ConversationActivity : ComponentActivity() { - private var conversationId: String? by mutableStateOf(value = null) + private var launchGeneration = 0 + private var launchRequest: ConversationLaunchRequest? by mutableStateOf(value = null) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - conversationId = extractConversationId(intent = intent) + launchGeneration = savedInstanceState?.getInt(LAUNCH_GENERATION_STATE_KEY) ?: 0 + + if (applyIntent(intent = intent, launchGeneration = launchGeneration)) { + return + } enableEdgeToEdge() setContent { AppTheme { ConversationScreen( - conversationId = conversationId, - onNavigateBack = ::finish, + launchRequest = launchRequest, + onNavigateBack = ::finishAfterTransition, ) } } @@ -37,11 +46,65 @@ internal class ConversationActivity : ComponentActivity() { override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) + + launchGeneration += 1 + applyIntent(intent = intent, launchGeneration = launchGeneration) + } + + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + + outState.putInt(LAUNCH_GENERATION_STATE_KEY, launchGeneration) + } + + private fun applyIntent( + intent: Intent, + launchGeneration: Int, + ): Boolean { setIntent(intent) - conversationId = extractConversationId(intent = intent) + + val goToConversationList = intent.getBooleanExtra( + UIIntents.UI_INTENT_EXTRA_GOTO_CONVERSATION_LIST, + false, + ) + + if (goToConversationList) { + redirectToConversationList() + return true + } + + launchRequest = ConversationLaunchRequest( + launchGeneration = launchGeneration, + conversationId = intent + .getStringExtra(UIIntents.UI_INTENT_EXTRA_CONVERSATION_ID), + draftData = intent.getParcelableExtra( + UIIntents.UI_INTENT_EXTRA_DRAFT_DATA, + MessageData::class.java, + ), + startupAttachmentUri = intent + .getStringExtra(UIIntents.UI_INTENT_EXTRA_ATTACHMENT_URI) + ?.takeUnless(TextUtils::isEmpty), + startupAttachmentType = intent + .getStringExtra(UIIntents.UI_INTENT_EXTRA_ATTACHMENT_TYPE) + ?.takeUnless(TextUtils::isEmpty), + ) + + intent.removeExtra(UIIntents.UI_INTENT_EXTRA_DRAFT_DATA) + + return false + } + + private fun redirectToConversationList() { + finish() + + Intent(this, ConversationListActivity::class.java) + .apply { + flags = Intent.FLAG_ACTIVITY_CLEAR_TOP + } + .let(::startActivity) } - private fun extractConversationId(intent: Intent?): String? { - return intent?.getStringExtra(UIIntents.UI_INTENT_EXTRA_CONVERSATION_ID) + private companion object { + private const val LAUNCH_GENERATION_STATE_KEY = "launch_generation" } } diff --git a/src/com/android/messaging/ui/conversation/v2/composer/delegate/ConversationDraftDelegate.kt b/src/com/android/messaging/ui/conversation/v2/composer/delegate/ConversationDraftDelegate.kt index af0f650f..121fb2b0 100644 --- a/src/com/android/messaging/ui/conversation/v2/composer/delegate/ConversationDraftDelegate.kt +++ b/src/com/android/messaging/ui/conversation/v2/composer/delegate/ConversationDraftDelegate.kt @@ -41,6 +41,11 @@ import kotlinx.coroutines.withContext internal interface ConversationDraftDelegate : ConversationScreenDelegate { fun onMessageTextChanged(messageText: String) + fun seedDraft( + conversationId: String, + draft: ConversationDraft, + ) + fun addAttachments(attachments: Collection) fun addPendingAttachment(pendingAttachment: ConversationDraftPendingAttachment) @@ -83,6 +88,7 @@ internal class ConversationDraftDelegateImpl @Inject constructor( private val draftSaveMutex = Mutex() private var boundScope: CoroutineScope? = null + private var pendingDraftSeed: PendingDraftSeed? = null override fun bind( scope: CoroutineScope, @@ -107,6 +113,17 @@ internal class ConversationDraftDelegateImpl @Inject constructor( } } + override fun seedDraft( + conversationId: String, + draft: ConversationDraft, + ) { + pendingDraftSeed = PendingDraftSeed( + conversationId = conversationId, + draft = draft, + ) + applyPendingDraftSeedIfPossible() + } + override fun addAttachments(attachments: Collection) { if (attachments.isEmpty()) { return @@ -246,6 +263,7 @@ internal class ConversationDraftDelegateImpl @Inject constructor( ) } } + applyPendingDraftSeedIfPossible() } } } @@ -270,6 +288,7 @@ internal class ConversationDraftDelegateImpl @Inject constructor( previousDraftEditorState = currentDraftEditorState DraftEditorState(conversationId = conversationId) } + applyPendingDraftSeedIfPossible() previousDraftEditorState ?.toSaveRequestOrNull() @@ -417,6 +436,19 @@ internal class ConversationDraftDelegateImpl @Inject constructor( } } + private fun applyPendingDraftSeedIfPossible() { + val pendingDraftSeed = pendingDraftSeed ?: return + + updateDraftEditorState { currentDraftEditorState -> + if (currentDraftEditorState.conversationId != pendingDraftSeed.conversationId) { + return@updateDraftEditorState currentDraftEditorState + } + + this.pendingDraftSeed = null + currentDraftEditorState.withSeededDraft(draft = pendingDraftSeed.draft) + } + } + private fun markConversationDraftAsIdle(conversationId: String) { updateDraftEditorState { currentDraftEditorState -> if (currentDraftEditorState.conversationId != conversationId) { @@ -484,3 +516,8 @@ internal class ConversationDraftDelegateImpl @Inject constructor( private const val DRAFT_AUTOSAVE_DELAY_MILLIS = 300L } } + +private data class PendingDraftSeed( + val conversationId: String, + val draft: ConversationDraft, +) diff --git a/src/com/android/messaging/ui/conversation/v2/composer/delegate/ConversationDraftEditorState.kt b/src/com/android/messaging/ui/conversation/v2/composer/delegate/ConversationDraftEditorState.kt index 92ddea4c..cb5a4e9e 100644 --- a/src/com/android/messaging/ui/conversation/v2/composer/delegate/ConversationDraftEditorState.kt +++ b/src/com/android/messaging/ui/conversation/v2/composer/delegate/ConversationDraftEditorState.kt @@ -66,6 +66,28 @@ internal data class DraftEditorState( } } + fun withSeededDraft(draft: ConversationDraft): DraftEditorState { + if (conversationId == null) { + return this + } + + val normalizedDraft = draft.copy( + selfParticipantId = when { + draft.selfParticipantId.isBlank() -> persistedDraft.selfParticipantId + else -> draft.selfParticipantId + }, + ) + + return copyWithNormalizedLocalEdits( + updatedLocalEdits = ConversationDraftEdits( + messageText = normalizedDraft.messageText, + subjectText = normalizedDraft.subjectText, + selfParticipantId = normalizedDraft.selfParticipantId, + attachments = normalizedDraft.attachments, + ), + ) + } + fun toSaveRequestOrNull(): DraftSaveRequest? { return when { conversationId == null -> null diff --git a/src/com/android/messaging/ui/conversation/v2/screen/ConversationScreen.kt b/src/com/android/messaging/ui/conversation/v2/screen/ConversationScreen.kt index 8f3dc2bf..2d0335ac 100644 --- a/src/com/android/messaging/ui/conversation/v2/screen/ConversationScreen.kt +++ b/src/com/android/messaging/ui/conversation/v2/screen/ConversationScreen.kt @@ -18,6 +18,9 @@ import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.geometry.Rect as ComposeRect +import androidx.compose.ui.layout.boundsInWindow +import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.testTag import androidx.lifecycle.Lifecycle import androidx.lifecycle.compose.LifecycleEventEffect @@ -32,13 +35,14 @@ import com.android.messaging.ui.conversation.v2.messages.model.message.Conversat import com.android.messaging.ui.conversation.v2.messages.model.message.ConversationMessagesUiState import com.android.messaging.ui.conversation.v2.messages.ui.ConversationMessages import com.android.messaging.ui.conversation.v2.metadata.ui.ConversationTopAppBar +import com.android.messaging.ui.conversation.v2.screen.model.ConversationLaunchRequest import com.android.messaging.ui.conversation.v2.screen.model.ConversationScreenScaffoldUiState import kotlinx.collections.immutable.ImmutableList @Composable internal fun ConversationScreen( modifier: Modifier = Modifier, - conversationId: String? = null, + launchRequest: ConversationLaunchRequest? = null, onNavigateBack: () -> Unit = {}, screenModel: ConversationScreenModel = viewModel(), ) { @@ -51,19 +55,31 @@ internal fun ConversationScreen( .mediaPickerOverlayUiState .collectAsStateWithLifecycle() - LaunchedEffect(conversationId) { - screenModel.onConversationChanged(conversationId = conversationId) + val hostBoundsState = remember { + mutableStateOf(value = null) + } + + val conversationId = launchRequest?.conversationId + + LaunchedEffect(launchRequest) { + launchRequest?.let(screenModel::onLaunchRequest) } LifecycleEventEffect(event = Lifecycle.Event.ON_STOP) { screenModel.persistDraft() } - ConversationScreenEffects(screenModel = screenModel) + ConversationScreenEffects( + screenModel = screenModel, + hostBoundsState = hostBoundsState, + ) Box( modifier = modifier - .fillMaxSize(), + .fillMaxSize() + .onGloballyPositioned { coordinates -> + hostBoundsState.value = coordinates.boundsInWindow() + }, ) { ConversationScreenScaffold( modifier = Modifier diff --git a/src/com/android/messaging/ui/conversation/v2/screen/ConversationScreenEffects.kt b/src/com/android/messaging/ui/conversation/v2/screen/ConversationScreenEffects.kt index d56b6ed9..a2cd9082 100644 --- a/src/com/android/messaging/ui/conversation/v2/screen/ConversationScreenEffects.kt +++ b/src/com/android/messaging/ui/conversation/v2/screen/ConversationScreenEffects.kt @@ -3,12 +3,14 @@ package com.android.messaging.ui.conversation.v2.screen import android.content.ContentResolver import android.content.Context import android.content.Intent +import android.graphics.Rect import android.net.Uri -import android.view.View import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.State +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.geometry.Rect as ComposeRect import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalView import androidx.core.net.toUri import com.android.messaging.R import com.android.messaging.ui.UIIntents @@ -16,26 +18,34 @@ import com.android.messaging.ui.conversation.v2.screen.model.ConversationScreenE import com.android.messaging.util.ContentType import com.android.messaging.util.UiUtils import com.android.messaging.util.UriUtil +import kotlin.math.roundToInt import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first import kotlinx.coroutines.withContext @Composable internal fun ConversationScreenEffects( screenModel: ConversationScreenModel, + hostBoundsState: State, ) { val context = LocalContext.current - val hostView = LocalView.current - LaunchedEffect(screenModel, context, hostView) { + LaunchedEffect(screenModel, context, hostBoundsState) { screenModel.effects.collect { effect -> when (effect) { is ConversationScreenEffect.OpenAttachmentPreview -> { openAttachmentPreview( context = context, - hostView = hostView, + hostBounds = hostBoundsState.value, contentUri = effect.contentUri, contentType = effect.contentType, imageCollectionUri = effect.imageCollectionUri, + awaitHostBounds = { + snapshotFlow { hostBoundsState.value } + .filterNotNull() + .first() + }, ) } @@ -63,18 +73,20 @@ private fun openExternalUri( private suspend fun openAttachmentPreview( context: Context, - hostView: View, + hostBounds: ComposeRect?, contentUri: String, contentType: String, imageCollectionUri: String?, + awaitHostBounds: suspend () -> ComposeRect, ) { val attachmentUri = contentUri.toUri() when { ContentType.isImageType(contentType) -> { + val resolvedHostBounds = hostBounds ?: awaitHostBounds() val isOpenedInternally = openImageAttachmentPreview( context = context, - hostView = hostView, + hostBounds = resolvedHostBounds, attachmentUri = attachmentUri, imageCollectionUri = imageCollectionUri, ) @@ -113,7 +125,7 @@ private suspend fun openAttachmentPreview( private fun openImageAttachmentPreview( context: Context, - hostView: View, + hostBounds: ComposeRect, attachmentUri: Uri, imageCollectionUri: String?, ): Boolean { @@ -123,7 +135,7 @@ private fun openImageAttachmentPreview( UIIntents.get().launchFullScreenPhotoViewer( activity, attachmentUri, - UiUtils.getMeasuredBoundsOnScreen(hostView), + hostBounds.toAndroidRect(), imageCollection, ) @@ -160,3 +172,12 @@ private suspend fun normalizeAttachmentUriForIntent( } } } + +private fun ComposeRect.toAndroidRect(): Rect { + return Rect( + left.roundToInt(), + top.roundToInt(), + right.roundToInt(), + bottom.roundToInt(), + ) +} diff --git a/src/com/android/messaging/ui/conversation/v2/screen/ConversationViewModel.kt b/src/com/android/messaging/ui/conversation/v2/screen/ConversationViewModel.kt index 8a460ae0..4cc9831f 100644 --- a/src/com/android/messaging/ui/conversation/v2/screen/ConversationViewModel.kt +++ b/src/com/android/messaging/ui/conversation/v2/screen/ConversationViewModel.kt @@ -3,6 +3,7 @@ package com.android.messaging.ui.conversation.v2.screen import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.android.messaging.data.conversation.mapper.ConversationMessageDataDraftMapper import com.android.messaging.data.media.model.ConversationMediaItem import com.android.messaging.datamodel.MessagingContentProvider import com.android.messaging.di.core.DefaultDispatcher @@ -14,6 +15,7 @@ import com.android.messaging.ui.conversation.v2.mediapicker.model.ConversationCa import com.android.messaging.ui.conversation.v2.messages.delegate.ConversationMessagesDelegate import com.android.messaging.ui.conversation.v2.metadata.delegate.ConversationMetadataDelegate import com.android.messaging.ui.conversation.v2.metadata.model.ConversationMetadataUiState +import com.android.messaging.ui.conversation.v2.screen.model.ConversationLaunchRequest import com.android.messaging.ui.conversation.v2.screen.model.ConversationMediaPickerOverlayUiState import com.android.messaging.ui.conversation.v2.screen.model.ConversationScreenEffect import com.android.messaging.ui.conversation.v2.screen.model.ConversationScreenScaffoldUiState @@ -34,7 +36,7 @@ internal interface ConversationScreenModel { val mediaPickerOverlayUiState: StateFlow val scaffoldUiState: StateFlow - fun onConversationChanged(conversationId: String?) + fun onLaunchRequest(launchRequest: ConversationLaunchRequest) fun onAttachmentClicked( attachment: ConversationComposerAttachmentUiState.Resolved, ) @@ -65,6 +67,7 @@ internal interface ConversationScreenModel { internal class ConversationViewModel @Inject constructor( private val conversationDraftDelegate: ConversationDraftDelegate, private val conversationMessagesDelegate: ConversationMessagesDelegate, + private val conversationMessageDataDraftMapper: ConversationMessageDataDraftMapper, private val conversationMediaPickerDelegate: ConversationMediaPickerDelegate, private val conversationMetadataDelegate: ConversationMetadataDelegate, private val conversationComposerUiStateMapper: ConversationComposerUiStateMapper, @@ -185,12 +188,61 @@ internal class ConversationViewModel @Inject constructor( } } - override fun onConversationChanged(conversationId: String?) { + override fun onLaunchRequest(launchRequest: ConversationLaunchRequest) { + updateConversationId(conversationId = launchRequest.conversationId) + + val processedLaunchGeneration = savedStateHandle.get( + PROCESSED_LAUNCH_GENERATION_KEY, + ) + if (processedLaunchGeneration == launchRequest.launchGeneration) { + return + } + + seedDraftIfPresent(launchRequest = launchRequest) + openStartupAttachmentIfPresent(launchRequest = launchRequest) + + savedStateHandle[PROCESSED_LAUNCH_GENERATION_KEY] = launchRequest.launchGeneration + } + + private fun updateConversationId(conversationId: String?) { if (conversationId != conversationIdFlow.value) { savedStateHandle[CONVERSATION_ID_KEY] = conversationId } } + private fun seedDraftIfPresent( + launchRequest: ConversationLaunchRequest, + ) { + val conversationId = launchRequest.conversationId ?: return + val draftData = launchRequest.draftData ?: return + + conversationDraftDelegate.seedDraft( + conversationId = conversationId, + draft = conversationMessageDataDraftMapper.map(messageData = draftData), + ) + } + + private fun openStartupAttachmentIfPresent( + launchRequest: ConversationLaunchRequest, + ) { + val contentUri = launchRequest.startupAttachmentUri ?: return + val contentType = launchRequest.startupAttachmentType ?: return + + val imageCollectionUri = launchRequest.conversationId + ?.let(MessagingContentProvider::buildConversationImagesUri) + ?.toString() + + viewModelScope.launch(defaultDispatcher) { + _effects.emit( + ConversationScreenEffect.OpenAttachmentPreview( + contentType = contentType, + contentUri = contentUri, + imageCollectionUri = imageCollectionUri, + ), + ) + } + } + override fun onAttachmentClicked( attachment: ConversationComposerAttachmentUiState.Resolved, ) { @@ -293,6 +345,7 @@ internal class ConversationViewModel @Inject constructor( private companion object { private const val CONVERSATION_ID_KEY = "conversation_id" + private const val PROCESSED_LAUNCH_GENERATION_KEY = "processed_launch_generation" private const val STATEFLOW_STOP_TIMEOUT_MILLIS = 5_000L } } diff --git a/src/com/android/messaging/ui/conversation/v2/screen/model/ConversationLaunchRequest.kt b/src/com/android/messaging/ui/conversation/v2/screen/model/ConversationLaunchRequest.kt new file mode 100644 index 00000000..b8f8103c --- /dev/null +++ b/src/com/android/messaging/ui/conversation/v2/screen/model/ConversationLaunchRequest.kt @@ -0,0 +1,13 @@ +package com.android.messaging.ui.conversation.v2.screen.model + +import androidx.compose.runtime.Immutable +import com.android.messaging.datamodel.data.MessageData + +@Immutable +internal data class ConversationLaunchRequest( + val launchGeneration: Int, + val conversationId: String?, + val draftData: MessageData? = null, + val startupAttachmentUri: String? = null, + val startupAttachmentType: String? = null, +) From 9ad0c3cd56edaebecd55007397901b9f9070f634 Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Tue, 14 Apr 2026 21:51:19 +0300 Subject: [PATCH 28/99] Add Compose Navigation dependencies --- app/build.gradle.kts | 7 ++ build.gradle.kts | 1 + gradle/libs.versions.toml | 10 ++ gradle/verification-metadata.xml | 167 +++++++++++++++++++++++++++++++ 4 files changed, 185 insertions(+) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index eb5d971b..1a77bb1b 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -8,6 +8,7 @@ plugins { alias(libs.plugins.hilt) alias(libs.plugins.kotlin.compose) alias(libs.plugins.kotlin.parcelize) + alias(libs.plugins.kotlin.serialization) alias(libs.plugins.ksp) } @@ -141,11 +142,16 @@ dependencies { implementation(libs.androidx.compose.material3) implementation(libs.androidx.compose.ui) implementation(libs.androidx.compose.ui.tooling.preview) + implementation(libs.androidx.hilt.lifecycle.viewmodel.compose) implementation(libs.androidx.lifecycle.livedata.ktx) implementation(libs.androidx.lifecycle.runtime.compose) implementation(libs.androidx.lifecycle.viewmodel.compose) implementation(libs.androidx.lifecycle.viewmodel.ktx) + implementation(libs.androidx.lifecycle.viewmodel.navigation3) + + implementation(libs.androidx.navigation3.runtime) + implementation(libs.androidx.navigation3.ui) implementation(libs.coil.compose) implementation(libs.coil.network.okhttp) @@ -159,6 +165,7 @@ dependencies { implementation(libs.kotlinx.collections.immutable) implementation(libs.kotlinx.coroutines.android) + implementation(libs.kotlinx.serialization.core) implementation(libs.libphonenumber) diff --git a/build.gradle.kts b/build.gradle.kts index 07e20db4..8c07aba4 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -7,6 +7,7 @@ plugins { alias(libs.plugins.hilt) apply false alias(libs.plugins.kotlin.compose) apply false alias(libs.plugins.kotlin.parcelize) apply false + alias(libs.plugins.kotlin.serialization) apply false alias(libs.plugins.ksp) apply false alias(libs.plugins.ktlint) } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 64c06b20..f9ffb9ee 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -3,12 +3,14 @@ agp = "9.1.0" detekt = "2.0.0-alpha.2" hilt = "2.59.2" kotlin = "2.3.20" +kotlinx-serialization = "1.11.0" ksp = "2.3.6" ktlint = "1.8.0" ktlint-gradle = "14.2.0" activity-compose = "1.13.0" appcompat = "1.7.1" +androidx-hilt = "1.3.0" camerax = "1.6.0" coil = "3.4.0" compose-bom = "2026.03.01" @@ -19,6 +21,7 @@ jsr305 = "3.0.2" kotlinx-collections-immutable = "0.4.0" libphonenumber = "9.0.26" lifecycle = "2.10.0" +navigation3 = "1.1.0" paging = "3.4.2" palette = "1.0.0" preference = "1.2.1" @@ -53,11 +56,16 @@ androidx-compose-ui-test-junit4 = { module = "androidx.compose.ui:ui-test-junit4 androidx-compose-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest" } androidx-compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" } androidx-compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" } +androidx-hilt-lifecycle-viewmodel-compose = { module = "androidx.hilt:hilt-lifecycle-viewmodel-compose", version.ref = "androidx-hilt" } androidx-lifecycle-livedata-ktx = { module = "androidx.lifecycle:lifecycle-livedata-ktx", version.ref = "lifecycle" } androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycle" } androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycle" } androidx-lifecycle-viewmodel-ktx = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "lifecycle" } +androidx-lifecycle-viewmodel-navigation3 = { module = "androidx.lifecycle:lifecycle-viewmodel-navigation3", version.ref = "lifecycle" } + +androidx-navigation3-runtime = { module = "androidx.navigation3:navigation3-runtime", version.ref = "navigation3" } +androidx-navigation3-ui = { module = "androidx.navigation3:navigation3-ui", version.ref = "navigation3" } androidx-paging-compose = { module = "androidx.paging:paging-compose", version.ref = "paging" } androidx-paging-runtime = { module = "androidx.paging:paging-runtime", version.ref = "paging" } @@ -79,6 +87,7 @@ hilt-compiler = { module = "com.google.dagger:hilt-compiler", version.ref = "hil kotlinx-collections-immutable = { module = "org.jetbrains.kotlinx:kotlinx-collections-immutable", version.ref = "kotlinx-collections-immutable" } kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" } kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" } +kotlinx-serialization-core = { module = "org.jetbrains.kotlinx:kotlinx-serialization-core", version.ref = "kotlinx-serialization" } libphonenumber = { module = "com.googlecode.libphonenumber:libphonenumber", version.ref = "libphonenumber" } @@ -106,5 +115,6 @@ hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } kotlin-parcelize = { id = "org.jetbrains.kotlin.plugin.parcelize", version.ref = "kotlin" } +kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } ktlint = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlint-gradle" } diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml index 0e21afda..22f6b44c 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -7829,5 +7829,172 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + From a3a2465b52d0ebfd64327cff3d150bfb430bebf8 Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Tue, 14 Apr 2026 22:53:46 +0300 Subject: [PATCH 29/99] Migrate to basic Compose navigation --- res/values/strings.xml | 2 + .../conversation/v2/ConversationActivity.kt | 6 +- .../ui/conversation/v2/entry/NewChatScreen.kt | 43 +++++++ .../v2/navigation/ConversationNavGraph.kt | 117 ++++++++++++++++++ .../v2/navigation/ConversationNavKey.kt | 23 ++++ .../recipientpicker/RecipientPickerScreen.kt | 60 +++++++++ .../v2/screen/ConversationScreen.kt | 4 +- 7 files changed, 250 insertions(+), 5 deletions(-) create mode 100644 src/com/android/messaging/ui/conversation/v2/entry/NewChatScreen.kt create mode 100644 src/com/android/messaging/ui/conversation/v2/navigation/ConversationNavGraph.kt create mode 100644 src/com/android/messaging/ui/conversation/v2/navigation/ConversationNavKey.kt create mode 100644 src/com/android/messaging/ui/conversation/v2/recipientpicker/RecipientPickerScreen.kt diff --git a/res/values/strings.xml b/res/values/strings.xml index e501415b..7658e458 100644 --- a/res/values/strings.xml +++ b/res/values/strings.xml @@ -556,6 +556,8 @@ Switch between entering text and numbers Add more participants Confirm participants + Add people + New group Start new conversation Select this item diff --git a/src/com/android/messaging/ui/conversation/v2/ConversationActivity.kt b/src/com/android/messaging/ui/conversation/v2/ConversationActivity.kt index 6ec9d524..205a4e08 100644 --- a/src/com/android/messaging/ui/conversation/v2/ConversationActivity.kt +++ b/src/com/android/messaging/ui/conversation/v2/ConversationActivity.kt @@ -11,7 +11,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import com.android.messaging.datamodel.data.MessageData import com.android.messaging.ui.UIIntents -import com.android.messaging.ui.conversation.v2.screen.ConversationScreen +import com.android.messaging.ui.conversation.v2.navigation.ConversationNavGraph import com.android.messaging.ui.conversation.v2.screen.model.ConversationLaunchRequest import com.android.messaging.ui.conversationlist.ConversationListActivity import com.android.messaging.ui.core.AppTheme @@ -36,9 +36,9 @@ internal class ConversationActivity : ComponentActivity() { setContent { AppTheme { - ConversationScreen( + ConversationNavGraph( launchRequest = launchRequest, - onNavigateBack = ::finishAfterTransition, + onFinish = ::finishAfterTransition, ) } } diff --git a/src/com/android/messaging/ui/conversation/v2/entry/NewChatScreen.kt b/src/com/android/messaging/ui/conversation/v2/entry/NewChatScreen.kt new file mode 100644 index 00000000..143f573b --- /dev/null +++ b/src/com/android/messaging/ui/conversation/v2/entry/NewChatScreen.kt @@ -0,0 +1,43 @@ +@file:OptIn(ExperimentalMaterial3Api::class) + +package com.android.messaging.ui.conversation.v2.entry + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import com.android.messaging.R + +@Composable +internal fun NewChatScreen( + modifier: Modifier = Modifier, +) { + Scaffold( + modifier = modifier, + topBar = { + TopAppBar( + title = { + Text(text = stringResource(id = R.string.start_new_conversation)) + }, + ) + }, + ) { contentPadding -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues = contentPadding), + contentAlignment = Alignment.Center, + ) { + Text( + text = "", + ) + } + } +} diff --git a/src/com/android/messaging/ui/conversation/v2/navigation/ConversationNavGraph.kt b/src/com/android/messaging/ui/conversation/v2/navigation/ConversationNavGraph.kt new file mode 100644 index 00000000..0cb1f4c6 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/v2/navigation/ConversationNavGraph.kt @@ -0,0 +1,117 @@ +package com.android.messaging.ui.conversation.v2.navigation + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator +import androidx.navigation3.runtime.NavKey +import androidx.navigation3.runtime.entryProvider +import androidx.navigation3.runtime.rememberNavBackStack +import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator +import androidx.navigation3.ui.NavDisplay +import com.android.messaging.ui.conversation.v2.entry.NewChatScreen +import com.android.messaging.ui.conversation.v2.recipientpicker.RecipientPickerScreen +import com.android.messaging.ui.conversation.v2.screen.ConversationScreen +import com.android.messaging.ui.conversation.v2.screen.model.ConversationLaunchRequest + +@Composable +internal fun ConversationNavGraph( + launchRequest: ConversationLaunchRequest?, + modifier: Modifier = Modifier, + onFinish: () -> Unit, +) { + val backStack = rememberNavBackStack(initialNavKey(launchRequest = launchRequest)) + + val entryDecorators = listOf( + rememberSaveableStateHolderNavEntryDecorator(), + rememberViewModelStoreNavEntryDecorator(), + ) + + val entryProvider = remember(launchRequest, onFinish) { + entryProvider { + entry { navKey -> + ConversationScreen( + launchRequest = launchRequestForConversation( + launchRequest = launchRequest, + conversationId = navKey.conversationId, + ), + onNavigateBack = { + popBackStackOrFinish( + backStack = backStack, + onFinish = onFinish, + ) + }, + ) + } + + entry { + NewChatScreen() + } + + entry { navKey -> + RecipientPickerScreen(mode = navKey.mode) + } + } + } + + LaunchedEffect(launchRequest) { + updateBackStackForLaunch( + backStack = backStack, + launchRequest = launchRequest, + ) + } + + NavDisplay( + backStack = backStack, + modifier = modifier, + onBack = { + popBackStackOrFinish( + backStack = backStack, + onFinish = onFinish, + ) + }, + entryDecorators = entryDecorators, + entryProvider = entryProvider, + ) +} + +private fun initialNavKey(launchRequest: ConversationLaunchRequest?): NavKey { + return launchRequest + ?.conversationId + ?.let(::ConversationNavKey) + ?: NewChatNavKey +} + +private fun launchRequestForConversation( + launchRequest: ConversationLaunchRequest?, + conversationId: String, +): ConversationLaunchRequest? { + return launchRequest?.copy(conversationId = conversationId) +} + +private fun updateBackStackForLaunch( + backStack: MutableList, + launchRequest: ConversationLaunchRequest?, +) { + val destination = initialNavKey(launchRequest = launchRequest) + + if (backStack.size == 1 && backStack.firstOrNull() == destination) { + return + } + + backStack.clear() + backStack.add(destination) +} + +private fun popBackStackOrFinish( + backStack: MutableList, + onFinish: () -> Unit, +) { + if (backStack.size > 1) { + backStack.removeAt(backStack.lastIndex) + return + } + + onFinish() +} diff --git a/src/com/android/messaging/ui/conversation/v2/navigation/ConversationNavKey.kt b/src/com/android/messaging/ui/conversation/v2/navigation/ConversationNavKey.kt new file mode 100644 index 00000000..6f4bdac5 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/v2/navigation/ConversationNavKey.kt @@ -0,0 +1,23 @@ +package com.android.messaging.ui.conversation.v2.navigation + +import androidx.navigation3.runtime.NavKey +import kotlinx.serialization.Serializable + +@Serializable +internal data object NewChatNavKey : NavKey + +@Serializable +internal data class ConversationNavKey( + val conversationId: String, +) : NavKey + +@Serializable +internal data class RecipientPickerNavKey( + val mode: RecipientPickerMode, +) : NavKey + +@Serializable +internal enum class RecipientPickerMode { + CREATE_GROUP, + ADD_PARTICIPANTS, +} diff --git a/src/com/android/messaging/ui/conversation/v2/recipientpicker/RecipientPickerScreen.kt b/src/com/android/messaging/ui/conversation/v2/recipientpicker/RecipientPickerScreen.kt new file mode 100644 index 00000000..c34da132 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/v2/recipientpicker/RecipientPickerScreen.kt @@ -0,0 +1,60 @@ +@file:OptIn(ExperimentalMaterial3Api::class) + +package com.android.messaging.ui.conversation.v2.recipientpicker + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import com.android.messaging.R +import com.android.messaging.ui.conversation.v2.navigation.RecipientPickerMode + +@Composable +internal fun RecipientPickerScreen( + mode: RecipientPickerMode, + modifier: Modifier = Modifier, +) { + Scaffold( + modifier = modifier, + topBar = { + TopAppBar( + title = { + Text(text = recipientPickerTitle(mode = mode)) + }, + ) + }, + ) { contentPadding -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues = contentPadding), + contentAlignment = Alignment.Center, + ) { + Text( + text = "", + ) + } + } +} + +@Composable +private fun recipientPickerTitle( + mode: RecipientPickerMode, +): String { + return when (mode) { + RecipientPickerMode.ADD_PARTICIPANTS -> { + stringResource(id = R.string.conversation_add_people) + } + + RecipientPickerMode.CREATE_GROUP -> { + stringResource(id = R.string.conversation_new_group) + } + } +} diff --git a/src/com/android/messaging/ui/conversation/v2/screen/ConversationScreen.kt b/src/com/android/messaging/ui/conversation/v2/screen/ConversationScreen.kt index 2d0335ac..7cb38eaf 100644 --- a/src/com/android/messaging/ui/conversation/v2/screen/ConversationScreen.kt +++ b/src/com/android/messaging/ui/conversation/v2/screen/ConversationScreen.kt @@ -22,10 +22,10 @@ import androidx.compose.ui.geometry.Rect as ComposeRect import androidx.compose.ui.layout.boundsInWindow import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.testTag +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.Lifecycle import androidx.lifecycle.compose.LifecycleEventEffect import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.lifecycle.viewmodel.compose.viewModel import com.android.messaging.ui.conversation.v2.CONVERSATION_LOADING_INDICATOR_TEST_TAG import com.android.messaging.ui.conversation.v2.composer.model.ConversationComposerAttachmentUiState import com.android.messaging.ui.conversation.v2.composer.ui.ConversationComposerSection @@ -44,7 +44,7 @@ internal fun ConversationScreen( modifier: Modifier = Modifier, launchRequest: ConversationLaunchRequest? = null, onNavigateBack: () -> Unit = {}, - screenModel: ConversationScreenModel = viewModel(), + screenModel: ConversationScreenModel = hiltViewModel(), ) { val messageFieldFocusRequester = remember { FocusRequester() From 78d6b983badb4af5e31f80bcdd16ecc4b3b394c1 Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Tue, 14 Apr 2026 23:40:30 +0300 Subject: [PATCH 30/99] Introduce conversation entry session model --- .../conversation/v2/ConversationActivity.kt | 6 +- .../v2/entry/ConversationEntryViewModel.kt | 185 ++++++++++++++++++ .../v2/entry/model/ConversationEntryEffect.kt | 14 ++ .../model/ConversationEntryLaunchRequest.kt} | 4 +- .../entry/model/ConversationEntryUiState.kt | 18 ++ .../v2/navigation/ConversationNavGraph.kt | 120 ++++++++++-- .../v2/screen/ConversationScreen.kt | 50 ++++- .../v2/screen/ConversationViewModel.kt | 62 +++--- 8 files changed, 401 insertions(+), 58 deletions(-) create mode 100644 src/com/android/messaging/ui/conversation/v2/entry/ConversationEntryViewModel.kt create mode 100644 src/com/android/messaging/ui/conversation/v2/entry/model/ConversationEntryEffect.kt rename src/com/android/messaging/ui/conversation/v2/{screen/model/ConversationLaunchRequest.kt => entry/model/ConversationEntryLaunchRequest.kt} (73%) create mode 100644 src/com/android/messaging/ui/conversation/v2/entry/model/ConversationEntryUiState.kt diff --git a/src/com/android/messaging/ui/conversation/v2/ConversationActivity.kt b/src/com/android/messaging/ui/conversation/v2/ConversationActivity.kt index 205a4e08..5ccd39cc 100644 --- a/src/com/android/messaging/ui/conversation/v2/ConversationActivity.kt +++ b/src/com/android/messaging/ui/conversation/v2/ConversationActivity.kt @@ -11,8 +11,8 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import com.android.messaging.datamodel.data.MessageData import com.android.messaging.ui.UIIntents +import com.android.messaging.ui.conversation.v2.entry.model.ConversationEntryLaunchRequest import com.android.messaging.ui.conversation.v2.navigation.ConversationNavGraph -import com.android.messaging.ui.conversation.v2.screen.model.ConversationLaunchRequest import com.android.messaging.ui.conversationlist.ConversationListActivity import com.android.messaging.ui.core.AppTheme import dagger.hilt.android.AndroidEntryPoint @@ -21,7 +21,7 @@ import dagger.hilt.android.AndroidEntryPoint internal class ConversationActivity : ComponentActivity() { private var launchGeneration = 0 - private var launchRequest: ConversationLaunchRequest? by mutableStateOf(value = null) + private var launchRequest: ConversationEntryLaunchRequest? by mutableStateOf(value = null) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -73,7 +73,7 @@ internal class ConversationActivity : ComponentActivity() { return true } - launchRequest = ConversationLaunchRequest( + launchRequest = ConversationEntryLaunchRequest( launchGeneration = launchGeneration, conversationId = intent .getStringExtra(UIIntents.UI_INTENT_EXTRA_CONVERSATION_ID), diff --git a/src/com/android/messaging/ui/conversation/v2/entry/ConversationEntryViewModel.kt b/src/com/android/messaging/ui/conversation/v2/entry/ConversationEntryViewModel.kt new file mode 100644 index 00000000..2ebfb2f0 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/v2/entry/ConversationEntryViewModel.kt @@ -0,0 +1,185 @@ +package com.android.messaging.ui.conversation.v2.entry + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import com.android.messaging.data.conversation.mapper.ConversationMessageDataDraftMapper +import com.android.messaging.datamodel.data.MessageData +import com.android.messaging.ui.conversation.v2.entry.model.ConversationEntryEffect +import com.android.messaging.ui.conversation.v2.entry.model.ConversationEntryLaunchRequest +import com.android.messaging.ui.conversation.v2.entry.model.ConversationEntryStartupAttachment +import com.android.messaging.ui.conversation.v2.entry.model.ConversationEntryUiState +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import javax.inject.Inject + +internal interface ConversationEntryModel { + val effects: Flow + val uiState: StateFlow + + fun onLaunchRequest(launchRequest: ConversationEntryLaunchRequest) + fun onDraftPayloadConsumed(conversationId: String) + fun onStartupAttachmentConsumed(conversationId: String) + fun navigateBack() + fun navigateToConversation(conversationId: String) + fun showMessage(messageResId: Int) +} + +@HiltViewModel +internal class ConversationEntryViewModel @Inject constructor( + private val conversationMessageDataDraftMapper: ConversationMessageDataDraftMapper, + private val savedStateHandle: SavedStateHandle, +) : ViewModel(), ConversationEntryModel { + + private val _effects = MutableSharedFlow( + extraBufferCapacity = 1, + ) + private val _uiState = MutableStateFlow( + value = restoreUiState(), + ) + + override val effects = _effects.asSharedFlow() + override val uiState = _uiState.asStateFlow() + + override fun onLaunchRequest(launchRequest: ConversationEntryLaunchRequest) { + val processedLaunchGeneration = savedStateHandle.get( + PROCESSED_LAUNCH_GENERATION_KEY, + ) + + if (processedLaunchGeneration == launchRequest.launchGeneration) { + return + } + + updateUiState( + ConversationEntryUiState( + launchGeneration = launchRequest.launchGeneration, + conversationId = launchRequest.conversationId, + pendingDraft = launchRequest.draftData?.let { messageData -> + conversationMessageDataDraftMapper.map(messageData = messageData) + }, + pendingStartupAttachment = launchRequest.toStartupAttachmentOrNull(), + ), + ) + savedStateHandle[PENDING_DRAFT_DATA_KEY] = launchRequest.draftData + savedStateHandle[PROCESSED_LAUNCH_GENERATION_KEY] = launchRequest.launchGeneration + } + + override fun onDraftPayloadConsumed(conversationId: String) { + val currentUiState = _uiState.value + + if (currentUiState.conversationId == conversationId && + currentUiState.pendingDraft != null + ) { + updateUiState( + currentUiState.copy( + pendingDraft = null, + ), + ) + savedStateHandle[PENDING_DRAFT_DATA_KEY] = null + } + } + + override fun onStartupAttachmentConsumed(conversationId: String) { + val currentUiState = _uiState.value + + if (currentUiState.conversationId == conversationId && + currentUiState.pendingStartupAttachment != null + ) { + updateUiState( + currentUiState.copy( + pendingStartupAttachment = null, + ), + ) + } + } + + override fun navigateBack() { + _effects.tryEmit(ConversationEntryEffect.NavigateBack) + } + + override fun navigateToConversation(conversationId: String) { + _effects.tryEmit( + ConversationEntryEffect.NavigateToConversation( + conversationId = conversationId, + ), + ) + } + + override fun showMessage(messageResId: Int) { + _effects.tryEmit( + ConversationEntryEffect.ShowMessage( + messageResId = messageResId, + ), + ) + } + + private fun restoreUiState(): ConversationEntryUiState { + val pendingDraftData = savedStateHandle.get( + PENDING_DRAFT_DATA_KEY, + ) + val startupAttachmentUri = savedStateHandle.get( + PENDING_STARTUP_ATTACHMENT_URI_KEY, + ) + val startupAttachmentType = savedStateHandle.get( + PENDING_STARTUP_ATTACHMENT_TYPE_KEY, + ) + + return ConversationEntryUiState( + launchGeneration = savedStateHandle[LAUNCH_GENERATION_KEY], + conversationId = savedStateHandle[CONVERSATION_ID_KEY], + pendingDraft = pendingDraftData?.let { messageData -> + conversationMessageDataDraftMapper.map(messageData = messageData) + }, + pendingStartupAttachment = when { + startupAttachmentUri != null && startupAttachmentType != null -> { + ConversationEntryStartupAttachment( + contentType = startupAttachmentType, + contentUri = startupAttachmentUri, + ) + } + + else -> null + }, + ) + } + + private fun updateUiState(uiState: ConversationEntryUiState) { + _uiState.value = uiState + + savedStateHandle[LAUNCH_GENERATION_KEY] = uiState.launchGeneration + savedStateHandle[CONVERSATION_ID_KEY] = uiState.conversationId + savedStateHandle[PENDING_STARTUP_ATTACHMENT_TYPE_KEY] = uiState + .pendingStartupAttachment + ?.contentType + + savedStateHandle[PENDING_STARTUP_ATTACHMENT_URI_KEY] = uiState + .pendingStartupAttachment + ?.contentUri + } + + private fun ConversationEntryLaunchRequest.toStartupAttachmentOrNull(): + ConversationEntryStartupAttachment? { + return when { + startupAttachmentUri != null && startupAttachmentType != null -> { + ConversationEntryStartupAttachment( + contentType = startupAttachmentType, + contentUri = startupAttachmentUri, + ) + } + else -> null + } + } + + private companion object { + private const val CONVERSATION_ID_KEY = "conversation_id" + private const val LAUNCH_GENERATION_KEY = "launch_generation" + private const val PENDING_DRAFT_DATA_KEY = "pending_draft_data" + private const val PENDING_STARTUP_ATTACHMENT_TYPE_KEY = "pending_startup_attachment_type" + private const val PENDING_STARTUP_ATTACHMENT_URI_KEY = "pending_startup_attachment_uri" + private const val PROCESSED_LAUNCH_GENERATION_KEY = "processed_launch_generation" + } +} diff --git a/src/com/android/messaging/ui/conversation/v2/entry/model/ConversationEntryEffect.kt b/src/com/android/messaging/ui/conversation/v2/entry/model/ConversationEntryEffect.kt new file mode 100644 index 00000000..3dba455f --- /dev/null +++ b/src/com/android/messaging/ui/conversation/v2/entry/model/ConversationEntryEffect.kt @@ -0,0 +1,14 @@ +package com.android.messaging.ui.conversation.v2.entry.model + +internal sealed interface ConversationEntryEffect { + + data class NavigateToConversation( + val conversationId: String, + ) : ConversationEntryEffect + + data object NavigateBack : ConversationEntryEffect + + data class ShowMessage( + val messageResId: Int, + ) : ConversationEntryEffect +} diff --git a/src/com/android/messaging/ui/conversation/v2/screen/model/ConversationLaunchRequest.kt b/src/com/android/messaging/ui/conversation/v2/entry/model/ConversationEntryLaunchRequest.kt similarity index 73% rename from src/com/android/messaging/ui/conversation/v2/screen/model/ConversationLaunchRequest.kt rename to src/com/android/messaging/ui/conversation/v2/entry/model/ConversationEntryLaunchRequest.kt index b8f8103c..76e0f037 100644 --- a/src/com/android/messaging/ui/conversation/v2/screen/model/ConversationLaunchRequest.kt +++ b/src/com/android/messaging/ui/conversation/v2/entry/model/ConversationEntryLaunchRequest.kt @@ -1,10 +1,10 @@ -package com.android.messaging.ui.conversation.v2.screen.model +package com.android.messaging.ui.conversation.v2.entry.model import androidx.compose.runtime.Immutable import com.android.messaging.datamodel.data.MessageData @Immutable -internal data class ConversationLaunchRequest( +internal data class ConversationEntryLaunchRequest( val launchGeneration: Int, val conversationId: String?, val draftData: MessageData? = null, diff --git a/src/com/android/messaging/ui/conversation/v2/entry/model/ConversationEntryUiState.kt b/src/com/android/messaging/ui/conversation/v2/entry/model/ConversationEntryUiState.kt new file mode 100644 index 00000000..4911398a --- /dev/null +++ b/src/com/android/messaging/ui/conversation/v2/entry/model/ConversationEntryUiState.kt @@ -0,0 +1,18 @@ +package com.android.messaging.ui.conversation.v2.entry.model + +import androidx.compose.runtime.Immutable +import com.android.messaging.data.conversation.model.draft.ConversationDraft + +@Immutable +internal data class ConversationEntryUiState( + val launchGeneration: Int? = null, + val conversationId: String? = null, + val pendingDraft: ConversationDraft? = null, + val pendingStartupAttachment: ConversationEntryStartupAttachment? = null, +) + +@Immutable +internal data class ConversationEntryStartupAttachment( + val contentType: String, + val contentUri: String, +) diff --git a/src/com/android/messaging/ui/conversation/v2/navigation/ConversationNavGraph.kt b/src/com/android/messaging/ui/conversation/v2/navigation/ConversationNavGraph.kt index 0cb1f4c6..46073630 100644 --- a/src/com/android/messaging/ui/conversation/v2/navigation/ConversationNavGraph.kt +++ b/src/com/android/messaging/ui/conversation/v2/navigation/ConversationNavGraph.kt @@ -2,25 +2,37 @@ package com.android.messaging.ui.conversation.v2.navigation import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator import androidx.navigation3.runtime.NavKey import androidx.navigation3.runtime.entryProvider import androidx.navigation3.runtime.rememberNavBackStack import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator import androidx.navigation3.ui.NavDisplay +import com.android.messaging.data.conversation.model.draft.ConversationDraft +import com.android.messaging.ui.conversation.v2.entry.ConversationEntryModel +import com.android.messaging.ui.conversation.v2.entry.ConversationEntryViewModel import com.android.messaging.ui.conversation.v2.entry.NewChatScreen +import com.android.messaging.ui.conversation.v2.entry.model.ConversationEntryEffect +import com.android.messaging.ui.conversation.v2.entry.model.ConversationEntryLaunchRequest +import com.android.messaging.ui.conversation.v2.entry.model.ConversationEntryStartupAttachment +import com.android.messaging.ui.conversation.v2.entry.model.ConversationEntryUiState import com.android.messaging.ui.conversation.v2.recipientpicker.RecipientPickerScreen import com.android.messaging.ui.conversation.v2.screen.ConversationScreen -import com.android.messaging.ui.conversation.v2.screen.model.ConversationLaunchRequest +import com.android.messaging.util.UiUtils @Composable internal fun ConversationNavGraph( - launchRequest: ConversationLaunchRequest?, + launchRequest: ConversationEntryLaunchRequest?, modifier: Modifier = Modifier, onFinish: () -> Unit, + entryModel: ConversationEntryModel = hiltViewModel(), ) { + val entryUiState by entryModel.uiState.collectAsStateWithLifecycle() val backStack = rememberNavBackStack(initialNavKey(launchRequest = launchRequest)) val entryDecorators = listOf( @@ -28,20 +40,39 @@ internal fun ConversationNavGraph( rememberViewModelStoreNavEntryDecorator(), ) - val entryProvider = remember(launchRequest, onFinish) { + val entryProvider = remember( + entryUiState, + onFinish, + ) { entryProvider { entry { navKey -> ConversationScreen( - launchRequest = launchRequestForConversation( - launchRequest = launchRequest, - conversationId = navKey.conversationId, - ), + conversationId = navKey.conversationId, + launchGeneration = entryUiState.launchGeneration, onNavigateBack = { popBackStackOrFinish( backStack = backStack, onFinish = onFinish, ) }, + pendingDraft = pendingDraftForConversation( + entryUiState = entryUiState, + conversationId = navKey.conversationId, + ), + pendingStartupAttachment = pendingStartupAttachmentForConversation( + entryUiState = entryUiState, + conversationId = navKey.conversationId, + ), + onPendingDraftConsumed = { + entryModel.onDraftPayloadConsumed( + conversationId = navKey.conversationId, + ) + }, + onPendingStartupAttachmentConsumed = { + entryModel.onStartupAttachmentConsumed( + conversationId = navKey.conversationId, + ) + }, ) } @@ -56,12 +87,23 @@ internal fun ConversationNavGraph( } LaunchedEffect(launchRequest) { + launchRequest?.let(entryModel::onLaunchRequest) updateBackStackForLaunch( backStack = backStack, launchRequest = launchRequest, ) } + LaunchedEffect(entryModel, onFinish) { + entryModel.effects.collect { effect -> + handleEntryEffect( + backStack = backStack, + effect = effect, + onFinish = onFinish, + ) + } + } + NavDisplay( backStack = backStack, modifier = modifier, @@ -76,23 +118,28 @@ internal fun ConversationNavGraph( ) } -private fun initialNavKey(launchRequest: ConversationLaunchRequest?): NavKey { +private fun initialNavKey(launchRequest: ConversationEntryLaunchRequest?): NavKey { return launchRequest ?.conversationId ?.let(::ConversationNavKey) ?: NewChatNavKey } -private fun launchRequestForConversation( - launchRequest: ConversationLaunchRequest?, +private fun pendingDraftForConversation( + entryUiState: ConversationEntryUiState, conversationId: String, -): ConversationLaunchRequest? { - return launchRequest?.copy(conversationId = conversationId) +): ConversationDraft? { + return when { + entryUiState.conversationId == conversationId -> { + entryUiState.pendingDraft + } + else -> null + } } private fun updateBackStackForLaunch( backStack: MutableList, - launchRequest: ConversationLaunchRequest?, + launchRequest: ConversationEntryLaunchRequest?, ) { val destination = initialNavKey(launchRequest = launchRequest) @@ -115,3 +162,50 @@ private fun popBackStackOrFinish( onFinish() } + +private fun pendingStartupAttachmentForConversation( + entryUiState: ConversationEntryUiState, + conversationId: String, +): ConversationEntryStartupAttachment? { + return when { + entryUiState.conversationId == conversationId -> { + entryUiState.pendingStartupAttachment + } + else -> null + } +} + +private fun handleEntryEffect( + backStack: MutableList, + effect: ConversationEntryEffect, + onFinish: () -> Unit, +) { + when (effect) { + is ConversationEntryEffect.NavigateBack -> { + popBackStackOrFinish( + backStack = backStack, + onFinish = onFinish, + ) + } + + is ConversationEntryEffect.NavigateToConversation -> { + navigateToConversation( + backStack = backStack, + conversationId = effect.conversationId, + ) + } + + is ConversationEntryEffect.ShowMessage -> { + UiUtils.showToastAtBottom(effect.messageResId) + } + } +} + +private fun navigateToConversation( + backStack: MutableList, + conversationId: String, +) { + ConversationNavKey(conversationId = conversationId) + .takeIf { it != backStack.lastOrNull() } + ?.let(backStack::add) +} diff --git a/src/com/android/messaging/ui/conversation/v2/screen/ConversationScreen.kt b/src/com/android/messaging/ui/conversation/v2/screen/ConversationScreen.kt index 7cb38eaf..a1e21e08 100644 --- a/src/com/android/messaging/ui/conversation/v2/screen/ConversationScreen.kt +++ b/src/com/android/messaging/ui/conversation/v2/screen/ConversationScreen.kt @@ -26,24 +26,30 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.Lifecycle import androidx.lifecycle.compose.LifecycleEventEffect import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.android.messaging.data.conversation.model.draft.ConversationDraft import com.android.messaging.ui.conversation.v2.CONVERSATION_LOADING_INDICATOR_TEST_TAG import com.android.messaging.ui.conversation.v2.composer.model.ConversationComposerAttachmentUiState import com.android.messaging.ui.conversation.v2.composer.ui.ConversationComposerSection +import com.android.messaging.ui.conversation.v2.entry.model.ConversationEntryStartupAttachment import com.android.messaging.ui.conversation.v2.mediapicker.ConversationMediaPickerOverlay import com.android.messaging.ui.conversation.v2.mediapicker.rememberConversationMediaPickerState import com.android.messaging.ui.conversation.v2.messages.model.message.ConversationMessageUiModel import com.android.messaging.ui.conversation.v2.messages.model.message.ConversationMessagesUiState import com.android.messaging.ui.conversation.v2.messages.ui.ConversationMessages import com.android.messaging.ui.conversation.v2.metadata.ui.ConversationTopAppBar -import com.android.messaging.ui.conversation.v2.screen.model.ConversationLaunchRequest import com.android.messaging.ui.conversation.v2.screen.model.ConversationScreenScaffoldUiState import kotlinx.collections.immutable.ImmutableList @Composable internal fun ConversationScreen( modifier: Modifier = Modifier, - launchRequest: ConversationLaunchRequest? = null, + conversationId: String? = null, + launchGeneration: Int? = null, onNavigateBack: () -> Unit = {}, + pendingDraft: ConversationDraft? = null, + pendingStartupAttachment: ConversationEntryStartupAttachment? = null, + onPendingDraftConsumed: () -> Unit = {}, + onPendingStartupAttachmentConsumed: () -> Unit = {}, screenModel: ConversationScreenModel = hiltViewModel(), ) { val messageFieldFocusRequester = remember { @@ -59,10 +65,44 @@ internal fun ConversationScreen( mutableStateOf(value = null) } - val conversationId = launchRequest?.conversationId + LaunchedEffect(conversationId) { + screenModel.onConversationIdChanged(conversationId = conversationId) + } - LaunchedEffect(launchRequest) { - launchRequest?.let(screenModel::onLaunchRequest) + LaunchedEffect( + conversationId, + launchGeneration, + pendingDraft, + ) { + if ( + conversationId != null && + launchGeneration != null && + pendingDraft != null + ) { + screenModel.onSeedDraft( + conversationId = conversationId, + draft = pendingDraft, + ) + onPendingDraftConsumed() + } + } + + LaunchedEffect( + conversationId, + launchGeneration, + pendingStartupAttachment, + ) { + if ( + conversationId != null && + launchGeneration != null && + pendingStartupAttachment != null + ) { + screenModel.onOpenStartupAttachment( + conversationId = conversationId, + startupAttachment = pendingStartupAttachment, + ) + onPendingStartupAttachmentConsumed() + } } LifecycleEventEffect(event = Lifecycle.Event.ON_STOP) { diff --git a/src/com/android/messaging/ui/conversation/v2/screen/ConversationViewModel.kt b/src/com/android/messaging/ui/conversation/v2/screen/ConversationViewModel.kt index 4cc9831f..718af270 100644 --- a/src/com/android/messaging/ui/conversation/v2/screen/ConversationViewModel.kt +++ b/src/com/android/messaging/ui/conversation/v2/screen/ConversationViewModel.kt @@ -3,24 +3,23 @@ package com.android.messaging.ui.conversation.v2.screen import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.android.messaging.data.conversation.mapper.ConversationMessageDataDraftMapper +import com.android.messaging.data.conversation.model.draft.ConversationDraft import com.android.messaging.data.media.model.ConversationMediaItem import com.android.messaging.datamodel.MessagingContentProvider import com.android.messaging.di.core.DefaultDispatcher import com.android.messaging.ui.conversation.v2.composer.delegate.ConversationDraftDelegate import com.android.messaging.ui.conversation.v2.composer.mapper.ConversationComposerUiStateMapper import com.android.messaging.ui.conversation.v2.composer.model.ConversationComposerAttachmentUiState +import com.android.messaging.ui.conversation.v2.entry.model.ConversationEntryStartupAttachment import com.android.messaging.ui.conversation.v2.mediapicker.ConversationMediaPickerDelegate import com.android.messaging.ui.conversation.v2.mediapicker.model.ConversationCapturedMedia import com.android.messaging.ui.conversation.v2.messages.delegate.ConversationMessagesDelegate import com.android.messaging.ui.conversation.v2.metadata.delegate.ConversationMetadataDelegate import com.android.messaging.ui.conversation.v2.metadata.model.ConversationMetadataUiState -import com.android.messaging.ui.conversation.v2.screen.model.ConversationLaunchRequest import com.android.messaging.ui.conversation.v2.screen.model.ConversationMediaPickerOverlayUiState import com.android.messaging.ui.conversation.v2.screen.model.ConversationScreenEffect import com.android.messaging.ui.conversation.v2.screen.model.ConversationScreenScaffoldUiState import dagger.hilt.android.lifecycle.HiltViewModel -import javax.inject.Inject import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow @@ -30,13 +29,24 @@ import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch +import javax.inject.Inject internal interface ConversationScreenModel { val effects: Flow val mediaPickerOverlayUiState: StateFlow val scaffoldUiState: StateFlow - fun onLaunchRequest(launchRequest: ConversationLaunchRequest) + fun onConversationIdChanged(conversationId: String?) + fun onOpenStartupAttachment( + conversationId: String, + startupAttachment: ConversationEntryStartupAttachment, + ) + + fun onSeedDraft( + conversationId: String, + draft: ConversationDraft, + ) + fun onAttachmentClicked( attachment: ConversationComposerAttachmentUiState.Resolved, ) @@ -67,7 +77,6 @@ internal interface ConversationScreenModel { internal class ConversationViewModel @Inject constructor( private val conversationDraftDelegate: ConversationDraftDelegate, private val conversationMessagesDelegate: ConversationMessagesDelegate, - private val conversationMessageDataDraftMapper: ConversationMessageDataDraftMapper, private val conversationMediaPickerDelegate: ConversationMediaPickerDelegate, private val conversationMetadataDelegate: ConversationMetadataDelegate, private val conversationComposerUiStateMapper: ConversationComposerUiStateMapper, @@ -188,20 +197,8 @@ internal class ConversationViewModel @Inject constructor( } } - override fun onLaunchRequest(launchRequest: ConversationLaunchRequest) { - updateConversationId(conversationId = launchRequest.conversationId) - - val processedLaunchGeneration = savedStateHandle.get( - PROCESSED_LAUNCH_GENERATION_KEY, - ) - if (processedLaunchGeneration == launchRequest.launchGeneration) { - return - } - - seedDraftIfPresent(launchRequest = launchRequest) - openStartupAttachmentIfPresent(launchRequest = launchRequest) - - savedStateHandle[PROCESSED_LAUNCH_GENERATION_KEY] = launchRequest.launchGeneration + override fun onConversationIdChanged(conversationId: String?) { + updateConversationId(conversationId = conversationId) } private fun updateConversationId(conversationId: String?) { @@ -210,33 +207,29 @@ internal class ConversationViewModel @Inject constructor( } } - private fun seedDraftIfPresent( - launchRequest: ConversationLaunchRequest, + override fun onSeedDraft( + conversationId: String, + draft: ConversationDraft, ) { - val conversationId = launchRequest.conversationId ?: return - val draftData = launchRequest.draftData ?: return - conversationDraftDelegate.seedDraft( conversationId = conversationId, - draft = conversationMessageDataDraftMapper.map(messageData = draftData), + draft = draft, ) } - private fun openStartupAttachmentIfPresent( - launchRequest: ConversationLaunchRequest, + override fun onOpenStartupAttachment( + conversationId: String, + startupAttachment: ConversationEntryStartupAttachment, ) { - val contentUri = launchRequest.startupAttachmentUri ?: return - val contentType = launchRequest.startupAttachmentType ?: return - - val imageCollectionUri = launchRequest.conversationId - ?.let(MessagingContentProvider::buildConversationImagesUri) + val imageCollectionUri = MessagingContentProvider + .buildConversationImagesUri(conversationId) ?.toString() viewModelScope.launch(defaultDispatcher) { _effects.emit( ConversationScreenEffect.OpenAttachmentPreview( - contentType = contentType, - contentUri = contentUri, + contentType = startupAttachment.contentType, + contentUri = startupAttachment.contentUri, imageCollectionUri = imageCollectionUri, ), ) @@ -345,7 +338,6 @@ internal class ConversationViewModel @Inject constructor( private companion object { private const val CONVERSATION_ID_KEY = "conversation_id" - private const val PROCESSED_LAUNCH_GENERATION_KEY = "processed_launch_generation" private const val STATEFLOW_STOP_TIMEOUT_MILLIS = 5_000L } } From 13bb9c0ca1b9984414136b948089ba00b6622b0e Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Tue, 14 Apr 2026 23:40:47 +0300 Subject: [PATCH 31/99] Use ConversationDraft for compose draft handoff --- .../mapper/ConversationMessageDataDraftMapper.kt | 3 ++- .../conversation/model/draft/ConversationDraft.kt | 7 ++++++- .../delegate/ConversationDraftEditorState.kt | 14 ++++++++------ 3 files changed, 16 insertions(+), 8 deletions(-) diff --git a/src/com/android/messaging/data/conversation/mapper/ConversationMessageDataDraftMapper.kt b/src/com/android/messaging/data/conversation/mapper/ConversationMessageDataDraftMapper.kt index 7a58ddb2..9f0f8e9d 100644 --- a/src/com/android/messaging/data/conversation/mapper/ConversationMessageDataDraftMapper.kt +++ b/src/com/android/messaging/data/conversation/mapper/ConversationMessageDataDraftMapper.kt @@ -5,6 +5,7 @@ import com.android.messaging.data.conversation.model.draft.ConversationDraftAtta import com.android.messaging.datamodel.data.MessageData import com.android.messaging.datamodel.data.MessagePartData import com.android.messaging.util.LogUtil +import kotlinx.collections.immutable.toImmutableList import javax.inject.Inject internal interface ConversationMessageDataDraftMapper { @@ -32,7 +33,7 @@ internal class ConversationMessageDataDraftMapperImpl @Inject constructor() : .asSequence() .filter { it.isAttachment } .mapNotNull(::createDraftAttachmentOrNull) - .toList(), + .toImmutableList(), ) } diff --git a/src/com/android/messaging/data/conversation/model/draft/ConversationDraft.kt b/src/com/android/messaging/data/conversation/model/draft/ConversationDraft.kt index a87c73ca..f1314aea 100644 --- a/src/com/android/messaging/data/conversation/model/draft/ConversationDraft.kt +++ b/src/com/android/messaging/data/conversation/model/draft/ConversationDraft.kt @@ -1,10 +1,15 @@ package com.android.messaging.data.conversation.model.draft +import androidx.compose.runtime.Immutable +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf + +@Immutable internal data class ConversationDraft( val messageText: String = "", val subjectText: String = "", val selfParticipantId: String = "", - val attachments: List = emptyList(), + val attachments: ImmutableList = persistentListOf(), val isCheckingDraft: Boolean = false, val isSending: Boolean = false, val messageCount: Int = 1, diff --git a/src/com/android/messaging/ui/conversation/v2/composer/delegate/ConversationDraftEditorState.kt b/src/com/android/messaging/ui/conversation/v2/composer/delegate/ConversationDraftEditorState.kt index cb5a4e9e..39325ff4 100644 --- a/src/com/android/messaging/ui/conversation/v2/composer/delegate/ConversationDraftEditorState.kt +++ b/src/com/android/messaging/ui/conversation/v2/composer/delegate/ConversationDraftEditorState.kt @@ -4,6 +4,8 @@ import com.android.messaging.data.conversation.model.draft.ConversationDraft import com.android.messaging.data.conversation.model.draft.ConversationDraftAttachment import com.android.messaging.data.conversation.model.draft.ConversationDraftPendingAttachment import com.android.messaging.ui.conversation.v2.composer.model.ConversationDraftState +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList internal data class DraftEditorState( val conversationId: String? = null, @@ -142,7 +144,7 @@ internal data class DraftEditorState( val updatedAttachments = effectiveDraft.attachments.toMutableList().apply { removeAt(attachmentIndex) - } + }.toImmutableList() return copyWithNormalizedLocalEdits( updatedLocalEdits = localEdits.copy(attachments = updatedAttachments), @@ -172,7 +174,7 @@ internal data class DraftEditorState( val updatedAttachments = currentAttachments.toMutableList().apply { this[attachmentIndex] = currentAttachment.copy(captionText = captionText) - } + }.toImmutableList() return copyWithNormalizedLocalEdits( updatedLocalEdits = localEdits.copy(attachments = updatedAttachments), @@ -362,7 +364,7 @@ internal data class ConversationDraftEdits( val messageText: String? = null, val subjectText: String? = null, val selfParticipantId: String? = null, - val attachments: List? = null, + val attachments: ImmutableList? = null, ) { val hasChanges: Boolean get() { @@ -392,9 +394,9 @@ internal data class ConversationDraftEdits( } private fun mergeDraftAttachments( - baseAttachments: List, + baseAttachments: ImmutableList, attachmentsToAdd: Collection, -): List { +): ImmutableList { if (attachmentsToAdd.isEmpty()) { return baseAttachments } @@ -410,7 +412,7 @@ private fun mergeDraftAttachments( return when { attachmentsToAppend.isEmpty() -> baseAttachments - else -> baseAttachments + attachmentsToAppend + else -> (baseAttachments + attachmentsToAppend).toImmutableList() } } From 77956c021afa0608ad5d657bd2d8071f6d7ded6f Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Thu, 16 Apr 2026 17:18:23 +0300 Subject: [PATCH 32/99] Add a separate build type for performance validation --- app/build.gradle.kts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 1a77bb1b..66f5fcdb 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -114,6 +114,14 @@ android { applicationIdSuffix = ".debug" resValue("string", "app_name", "Messaging d") } + + create("perf") { + initWith(getByName("release")) + applicationIdSuffix = ".debug" + matchingFallbacks += listOf("release") + resValue("string", "app_name", "Messaging d") + signingConfig = signingConfigs.getByName("debug") + } } lint { From 06b17615aa2e5217b201998e1ed2b12a04fc5f0a Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Thu, 16 Apr 2026 17:30:10 +0300 Subject: [PATCH 33/99] Add recipient picker search stack --- .../model/recipient/ConversationRecipient.kt | 12 + .../repository/ConversationRecipientsPage.kt | 9 + .../ConversationRecipientsRepository.kt | 399 ++++++++++++++++++ .../conversation/ConversationBindsModule.kt | 24 ++ .../IsReadContactsPermissionGranted.kt | 25 ++ .../usecase/ResolveConversationId.kt | 85 ++++ .../model/ResolveConversationIdResult.kt | 11 + .../RecipientPickerViewModel.kt | 347 +++++++++++++++ .../model/RecipientPickerUiState.kt | 16 + 9 files changed, 928 insertions(+) create mode 100644 src/com/android/messaging/data/conversation/model/recipient/ConversationRecipient.kt create mode 100644 src/com/android/messaging/data/conversation/repository/ConversationRecipientsPage.kt create mode 100644 src/com/android/messaging/data/conversation/repository/ConversationRecipientsRepository.kt create mode 100644 src/com/android/messaging/domain/contacts/usecase/IsReadContactsPermissionGranted.kt create mode 100644 src/com/android/messaging/domain/conversation/usecase/ResolveConversationId.kt create mode 100644 src/com/android/messaging/domain/conversation/usecase/model/ResolveConversationIdResult.kt create mode 100644 src/com/android/messaging/ui/conversation/v2/recipientpicker/RecipientPickerViewModel.kt create mode 100644 src/com/android/messaging/ui/conversation/v2/recipientpicker/model/RecipientPickerUiState.kt diff --git a/src/com/android/messaging/data/conversation/model/recipient/ConversationRecipient.kt b/src/com/android/messaging/data/conversation/model/recipient/ConversationRecipient.kt new file mode 100644 index 00000000..173cd195 --- /dev/null +++ b/src/com/android/messaging/data/conversation/model/recipient/ConversationRecipient.kt @@ -0,0 +1,12 @@ +package com.android.messaging.data.conversation.model.recipient + +import androidx.compose.runtime.Immutable + +@Immutable +internal data class ConversationRecipient( + val id: String, + val displayName: String, + val destination: String, + val photoUri: String? = null, + val secondaryText: String? = null, +) diff --git a/src/com/android/messaging/data/conversation/repository/ConversationRecipientsPage.kt b/src/com/android/messaging/data/conversation/repository/ConversationRecipientsPage.kt new file mode 100644 index 00000000..0bd1ff48 --- /dev/null +++ b/src/com/android/messaging/data/conversation/repository/ConversationRecipientsPage.kt @@ -0,0 +1,9 @@ +package com.android.messaging.data.conversation.repository + +import com.android.messaging.data.conversation.model.recipient.ConversationRecipient +import kotlinx.collections.immutable.ImmutableList + +internal data class ConversationRecipientsPage( + val recipients: ImmutableList, + val nextOffset: Int?, +) diff --git a/src/com/android/messaging/data/conversation/repository/ConversationRecipientsRepository.kt b/src/com/android/messaging/data/conversation/repository/ConversationRecipientsRepository.kt new file mode 100644 index 00000000..b3a86e88 --- /dev/null +++ b/src/com/android/messaging/data/conversation/repository/ConversationRecipientsRepository.kt @@ -0,0 +1,399 @@ +package com.android.messaging.data.conversation.repository + +import android.content.ContentResolver +import android.database.Cursor +import android.net.Uri +import android.os.Bundle +import android.provider.ContactsContract +import android.provider.ContactsContract.CommonDataKinds.Email +import android.provider.ContactsContract.CommonDataKinds.Phone +import android.provider.ContactsContract.Directory +import com.android.messaging.data.conversation.model.recipient.ConversationRecipient +import com.android.messaging.di.core.IoDispatcher +import com.android.messaging.util.core.extension.typedFlow +import javax.inject.Inject +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toPersistentList +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOn + +internal interface ConversationRecipientsRepository { + + fun searchRecipients( + query: String, + offset: Int, + ): Flow +} + +internal class ConversationRecipientsRepositoryImpl @Inject constructor( + private val contentResolver: ContentResolver, + @param:IoDispatcher + private val ioDispatcher: CoroutineDispatcher, +) : ConversationRecipientsRepository { + + override fun searchRecipients( + query: String, + offset: Int, + ): Flow { + return typedFlow { + queryRecipients( + query = query, + offset = offset, + ) + }.flowOn(ioDispatcher) + } + + private fun queryRecipients( + query: String, + offset: Int, + ): ConversationRecipientsPage { + val recipients = when { + query.isBlank() -> queryPhoneRecipients(query = query) + else -> queryMergedRecipients(query = query) + } + + return paginateRecipients( + recipients = recipients, + offset = offset, + ) + } + + private fun queryMergedRecipients(query: String): ImmutableList { + val phoneRecipients = queryPhoneRecipients(query = query) + val emailRecipients = queryEmailRecipients(query = query) + + val mergedRecipients = mergeRecipients( + phoneRecipients = phoneRecipients, + emailRecipients = emailRecipients, + ) + + val shouldUseFallback = mergedRecipients.isEmpty() && + shouldUsePhoneDigitsFallback(query = query) + + return when { + shouldUseFallback -> queryPhoneRecipients( + query = "", + matchesRecipient = createPhoneDigitsMatcher(query = query), + ) + else -> mergedRecipients + } + } + + private fun queryPhoneRecipients( + query: String, + matchesRecipient: (ConversationRecipient) -> Boolean = { true }, + ): ImmutableList { + val uri = when { + query.isBlank() -> createDefaultPhoneQueryUri() + else -> createPhoneQueryUri(query = query) + } + + return queryRecipientEntries( + uri = uri, + projection = phoneProjection, + queryArgs = phoneQueryArgs, + destinationColumnName = Phone.NUMBER, + matchesRecipient = matchesRecipient, + ) + } + + private fun queryEmailRecipients(query: String): ImmutableList { + return when { + query.isNotBlank() -> { + queryRecipientEntries( + uri = createEmailQueryUri(query = query), + projection = emailProjection, + queryArgs = emailQueryArgs, + destinationColumnName = Email.ADDRESS, + matchesRecipient = { true }, + ) + } + + else -> persistentListOf() + } + } + + private fun queryRecipientEntries( + uri: Uri, + projection: Array, + queryArgs: Bundle, + destinationColumnName: String, + matchesRecipient: (ConversationRecipient) -> Boolean, + ): ImmutableList { + return contentResolver + .query( + uri, + projection, + queryArgs, + null, + ) + ?.use { cursor -> + val recipientCursorColumns = resolveRecipientCursorColumns( + cursor = cursor, + destinationColumnName = destinationColumnName, + ) + + mapRecipientEntries( + cursor = cursor, + recipientCursorColumns = recipientCursorColumns, + matchesRecipient = matchesRecipient, + ) + } + ?: persistentListOf() + } + + private fun createDefaultPhoneQueryUri(): Uri { + return Phone.CONTENT_URI + .buildUpon() + .appendQueryParameter( + ContactsContract.DIRECTORY_PARAM_KEY, + Directory.DEFAULT.toString(), + ) + .build() + } + + private fun createEmailQueryUri(query: String): Uri { + return Email.CONTENT_FILTER_URI + .buildUpon() + .appendPath(query) + .appendQueryParameter( + ContactsContract.DIRECTORY_PARAM_KEY, + Directory.DEFAULT.toString(), + ) + .build() + } + + private fun createPhoneQueryUri(query: String): Uri { + return Phone.CONTENT_FILTER_URI + .buildUpon() + .appendPath(query) + .appendQueryParameter( + ContactsContract.DIRECTORY_PARAM_KEY, + Directory.DEFAULT.toString(), + ) + .build() + } + + private fun mapRecipientEntries( + cursor: Cursor, + recipientCursorColumns: RecipientCursorColumns, + matchesRecipient: (ConversationRecipient) -> Boolean, + ): ImmutableList { + val recipients = persistentListOf().builder() + + while (cursor.moveToNext()) { + val recipientEntry = mapRecipientEntry( + cursor = cursor, + recipientCursorColumns = recipientCursorColumns, + ) ?: continue + + if (!matchesRecipient(recipientEntry.recipient)) { + continue + } + + recipients.add(recipientEntry) + } + + return recipients.build() + } + + private fun resolveRecipientCursorColumns( + cursor: Cursor, + destinationColumnName: String, + ): RecipientCursorColumns { + return RecipientCursorColumns( + dataIdIndex = cursor.getColumnIndexOrThrow(Phone._ID), + destinationIndex = cursor.getColumnIndexOrThrow(destinationColumnName), + displayNameIndex = cursor.getColumnIndexOrThrow(Phone.DISPLAY_NAME_PRIMARY), + photoUriIndex = cursor.getColumnIndexOrThrow(Phone.PHOTO_THUMBNAIL_URI), + sortKeyIndex = cursor.getColumnIndexOrThrow(Phone.SORT_KEY_PRIMARY), + ) + } + + private fun mapRecipientEntry( + cursor: Cursor, + recipientCursorColumns: RecipientCursorColumns, + ): RecipientSearchEntry? { + val destination = cursor + .getString(recipientCursorColumns.destinationIndex) + ?.trim() + .orEmpty() + + if (destination.isBlank()) { + return null + } + + val displayName = cursor + .getString(recipientCursorColumns.displayNameIndex) + ?.trim() + .orEmpty() + .ifBlank { destination } + + val photoUri = cursor + .getString(recipientCursorColumns.photoUriIndex) + ?.takeIf { it.isNotBlank() } + + val secondaryText = when { + displayName == destination -> null + else -> destination + } + + return RecipientSearchEntry( + recipient = ConversationRecipient( + id = cursor.getLong(recipientCursorColumns.dataIdIndex).toString(), + displayName = displayName, + destination = destination, + photoUri = photoUri, + secondaryText = secondaryText, + ), + sortKey = cursor + .getString(recipientCursorColumns.sortKeyIndex) + ?.trim() + .orEmpty(), + ) + } + + private fun mergeRecipients( + phoneRecipients: List, + emailRecipients: List, + ): ImmutableList { + val sortedRecipients = (phoneRecipients + emailRecipients).sortedWith( + compareBy { it.sortKey } + .thenBy { it.recipient.displayName } + .thenBy { it.recipient.destination }, + ) + + val seenDestinations = LinkedHashSet() + + return sortedRecipients + .asSequence() + .filter { recipient -> + seenDestinations.add(recipient.recipient.destination) + } + .toPersistentList() + } + + private fun paginateRecipients( + recipients: List, + offset: Int, + ): ConversationRecipientsPage { + if (offset >= recipients.size) { + return emptyRecipientsPage() + } + + val pagedRecipients = persistentListOf().builder() + + for (index in offset until recipients.size) { + if (pagedRecipients.size == PAGE_SIZE) { + return ConversationRecipientsPage( + recipients = pagedRecipients.build(), + nextOffset = index, + ) + } + + pagedRecipients.add(recipients[index].recipient) + } + + return ConversationRecipientsPage( + recipients = pagedRecipients.build(), + nextOffset = null, + ) + } + + private fun shouldUsePhoneDigitsFallback(query: String): Boolean { + return query.any { character -> character.isDigit() } + } + + private fun createPhoneDigitsMatcher(query: String): (ConversationRecipient) -> Boolean { + val queryDigits = extractDigits(value = query) + + return { recipient -> + val destinationDigits = extractDigits(value = recipient.destination) + destinationDigits.contains(queryDigits) + } + } + + private fun extractDigits(value: String): String { + return value.filter { character -> character.isDigit() } + } + + private fun emptyRecipientsPage(): ConversationRecipientsPage { + return ConversationRecipientsPage( + recipients = persistentListOf(), + nextOffset = null, + ) + } + + private companion object { + private const val PAGE_SIZE = 200 + + private val phoneProjection by lazy { + arrayOf( + Phone.CONTACT_ID, + Phone.DISPLAY_NAME_PRIMARY, + Phone.PHOTO_THUMBNAIL_URI, + Phone.NUMBER, + Phone.TYPE, + Phone.LABEL, + Phone.LOOKUP_KEY, + Phone._ID, + Phone.SORT_KEY_PRIMARY, + ) + } + + private val emailProjection by lazy { + arrayOf( + Email.CONTACT_ID, + Email.DISPLAY_NAME_PRIMARY, + Email.PHOTO_THUMBNAIL_URI, + Email.ADDRESS, + Email.TYPE, + Email.LABEL, + Email.LOOKUP_KEY, + Email._ID, + Email.SORT_KEY_PRIMARY, + ) + } + + private val phoneQueryArgs by lazy { + Bundle().apply { + putStringArray( + ContentResolver.QUERY_ARG_SORT_COLUMNS, + arrayOf(Phone.SORT_KEY_PRIMARY), + ) + putInt( + ContentResolver.QUERY_ARG_SORT_DIRECTION, + ContentResolver.QUERY_SORT_DIRECTION_ASCENDING, + ) + } + } + + private val emailQueryArgs by lazy { + Bundle().apply { + putStringArray( + ContentResolver.QUERY_ARG_SORT_COLUMNS, + arrayOf(Email.SORT_KEY_PRIMARY), + ) + putInt( + ContentResolver.QUERY_ARG_SORT_DIRECTION, + ContentResolver.QUERY_SORT_DIRECTION_ASCENDING, + ) + } + } + } +} + +private data class RecipientCursorColumns( + val dataIdIndex: Int, + val destinationIndex: Int, + val displayNameIndex: Int, + val photoUriIndex: Int, + val sortKeyIndex: Int, +) + +private data class RecipientSearchEntry( + val recipient: ConversationRecipient, + val sortKey: String, +) diff --git a/src/com/android/messaging/di/conversation/ConversationBindsModule.kt b/src/com/android/messaging/di/conversation/ConversationBindsModule.kt index 3798874e..377423a9 100644 --- a/src/com/android/messaging/di/conversation/ConversationBindsModule.kt +++ b/src/com/android/messaging/di/conversation/ConversationBindsModule.kt @@ -10,10 +10,16 @@ import com.android.messaging.data.conversation.repository.ConversationDraftsRepo import com.android.messaging.data.conversation.repository.ConversationDraftsRepositoryImpl import com.android.messaging.data.conversation.repository.ConversationMetadataNotifier import com.android.messaging.data.conversation.repository.ConversationMetadataNotifierImpl +import com.android.messaging.data.conversation.repository.ConversationRecipientsRepository +import com.android.messaging.data.conversation.repository.ConversationRecipientsRepositoryImpl import com.android.messaging.data.conversation.repository.ConversationsRepository import com.android.messaging.data.conversation.repository.ConversationsRepositoryImpl import com.android.messaging.data.media.repository.ConversationMediaRepository import com.android.messaging.data.media.repository.ConversationMediaRepositoryImpl +import com.android.messaging.domain.contacts.usecase.IsReadContactsPermissionGranted +import com.android.messaging.domain.contacts.usecase.IsReadContactsPermissionGrantedImpl +import com.android.messaging.domain.conversation.usecase.ResolveConversationId +import com.android.messaging.domain.conversation.usecase.ResolveConversationIdImpl import com.android.messaging.domain.conversation.usecase.SendConversationDraft import com.android.messaging.domain.conversation.usecase.SendConversationDraftImpl import com.android.messaging.ui.conversation.v2.composer.mapper.ConversationComposerUiStateMapper @@ -64,6 +70,24 @@ internal abstract class ConversationBindsModule { impl: ConversationDraftsRepositoryImpl, ): ConversationDraftsRepository + @Binds + @Reusable + abstract fun bindConversationRecipientsRepository( + impl: ConversationRecipientsRepositoryImpl, + ): ConversationRecipientsRepository + + @Binds + @Reusable + abstract fun bindIsReadContactsPermissionGranted( + impl: IsReadContactsPermissionGrantedImpl, + ): IsReadContactsPermissionGranted + + @Binds + @Reusable + abstract fun bindResolveConversationId( + impl: ResolveConversationIdImpl, + ): ResolveConversationId + @Binds @Reusable abstract fun bindConversationsRepository( diff --git a/src/com/android/messaging/domain/contacts/usecase/IsReadContactsPermissionGranted.kt b/src/com/android/messaging/domain/contacts/usecase/IsReadContactsPermissionGranted.kt new file mode 100644 index 00000000..840a6dba --- /dev/null +++ b/src/com/android/messaging/domain/contacts/usecase/IsReadContactsPermissionGranted.kt @@ -0,0 +1,25 @@ +package com.android.messaging.domain.contacts.usecase + +import android.Manifest +import android.content.Context +import android.content.pm.PackageManager +import androidx.core.content.ContextCompat +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject + +internal interface IsReadContactsPermissionGranted { + operator fun invoke(): Boolean +} + +internal class IsReadContactsPermissionGrantedImpl @Inject constructor( + @param:ApplicationContext + private val context: Context, +) : IsReadContactsPermissionGranted { + + override fun invoke(): Boolean { + return ContextCompat.checkSelfPermission( + context, + Manifest.permission.READ_CONTACTS, + ) == PackageManager.PERMISSION_GRANTED + } +} diff --git a/src/com/android/messaging/domain/conversation/usecase/ResolveConversationId.kt b/src/com/android/messaging/domain/conversation/usecase/ResolveConversationId.kt new file mode 100644 index 00000000..84041454 --- /dev/null +++ b/src/com/android/messaging/domain/conversation/usecase/ResolveConversationId.kt @@ -0,0 +1,85 @@ +package com.android.messaging.domain.conversation.usecase + +import com.android.messaging.datamodel.action.ActionMonitor +import com.android.messaging.datamodel.action.GetOrCreateConversationAction +import com.android.messaging.datamodel.data.ParticipantData +import com.android.messaging.di.core.MainDispatcher +import com.android.messaging.domain.conversation.usecase.model.ResolveConversationIdResult +import javax.inject.Inject +import kotlin.coroutines.resume +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withContext + +internal interface ResolveConversationId { + suspend operator fun invoke( + destinations: List, + ): ResolveConversationIdResult +} + +// TODO: Get rid of legacy GetOrCreateConversationAction +internal class ResolveConversationIdImpl @Inject constructor( + @param:MainDispatcher + private val mainDispatcher: CoroutineDispatcher, +) : ResolveConversationId { + + override suspend operator fun invoke( + destinations: List, + ): ResolveConversationIdResult { + val participants = createParticipants(destinations = destinations) + + if (participants.isEmpty()) { + return ResolveConversationIdResult.EmptyDestinations + } + + return withContext(context = mainDispatcher) { + suspendCancellableCoroutine { continuation -> + var actionMonitor: ActionMonitor? = null + + actionMonitor = GetOrCreateConversationAction.getOrCreateConversation( + participants, + null, + object : GetOrCreateConversationAction.GetOrCreateConversationActionListener { + override fun onGetOrCreateConversationSucceeded( + monitor: ActionMonitor, + data: Any?, + conversationId: String, + ) { + if (continuation.isActive) { + continuation.resume( + ResolveConversationIdResult.Resolved( + conversationId = conversationId, + ), + ) + } + } + + override fun onGetOrCreateConversationFailed( + monitor: ActionMonitor, + data: Any?, + ) { + if (continuation.isActive) { + continuation.resume(ResolveConversationIdResult.NotResolved) + } + } + }, + ) + + continuation.invokeOnCancellation { + actionMonitor?.unregister() + } + } + } + } + + private fun createParticipants( + destinations: List, + ): ArrayList { + return destinations + .asSequence() + .map { it.trim() } + .filter { it.isNotEmpty() } + .map(ParticipantData::getFromRawPhoneBySystemLocale) + .toCollection(ArrayList(destinations.size)) + } +} diff --git a/src/com/android/messaging/domain/conversation/usecase/model/ResolveConversationIdResult.kt b/src/com/android/messaging/domain/conversation/usecase/model/ResolveConversationIdResult.kt new file mode 100644 index 00000000..b8bf8a75 --- /dev/null +++ b/src/com/android/messaging/domain/conversation/usecase/model/ResolveConversationIdResult.kt @@ -0,0 +1,11 @@ +package com.android.messaging.domain.conversation.usecase.model + +internal sealed interface ResolveConversationIdResult { + data object EmptyDestinations : ResolveConversationIdResult + + data object NotResolved : ResolveConversationIdResult + + data class Resolved( + val conversationId: String, + ) : ResolveConversationIdResult +} diff --git a/src/com/android/messaging/ui/conversation/v2/recipientpicker/RecipientPickerViewModel.kt b/src/com/android/messaging/ui/conversation/v2/recipientpicker/RecipientPickerViewModel.kt new file mode 100644 index 00000000..8ac7715c --- /dev/null +++ b/src/com/android/messaging/ui/conversation/v2/recipientpicker/RecipientPickerViewModel.kt @@ -0,0 +1,347 @@ +package com.android.messaging.ui.conversation.v2.recipientpicker + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.android.messaging.data.conversation.model.recipient.ConversationRecipient +import com.android.messaging.data.conversation.repository.ConversationRecipientsPage +import com.android.messaging.data.conversation.repository.ConversationRecipientsRepository +import com.android.messaging.di.core.DefaultDispatcher +import com.android.messaging.domain.contacts.usecase.IsReadContactsPermissionGranted +import com.android.messaging.ui.conversation.v2.recipientpicker.model.RecipientPickerUiState +import dagger.hilt.android.lifecycle.HiltViewModel +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.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +internal interface RecipientPickerModel { + val uiState: StateFlow + + fun onLoadMore() + + fun onQueryChanged(query: String) +} + +@HiltViewModel +internal class RecipientPickerViewModel @Inject constructor( + private val conversationRecipientsRepository: ConversationRecipientsRepository, + private val isReadContactsPermissionGranted: IsReadContactsPermissionGranted, + @param:DefaultDispatcher + private val defaultDispatcher: CoroutineDispatcher, + private val savedStateHandle: SavedStateHandle, +) : ViewModel(), + RecipientPickerModel { + + private val queryFlow: StateFlow = savedStateHandle.getStateFlow( + key = SEARCH_QUERY_KEY, + initialValue = "", + ) + + private val _uiState = MutableStateFlow( + RecipientPickerUiState( + query = queryFlow.value, + isLoading = false, + ), + ) + + private var searchSession = RecipientSearchSession( + effectiveQuery = queryFlow.value, + hasCompletedInitialLoad = false, + nextPageOffset = null, + ) + private val searchSessionMutex = Mutex() + + override val uiState = _uiState.asStateFlow() + + init { + bindQueryFlow() + } + + private fun bindQueryFlow() { + viewModelScope.launch(defaultDispatcher) { + queryFlow.collectLatest { query -> + handleQueryChanged(query = query) + } + } + } + + private suspend fun handleQueryChanged(query: String) { + if (!isReadContactsPermissionGranted()) { + applyPermissionDeniedState(query = query) + return + } + + startSearch(query = query) + } + + override fun onLoadMore() { + viewModelScope.launch(defaultDispatcher) { + val loadMoreRequest = createLoadMoreRequest() ?: return@launch + loadMore(request = loadMoreRequest) + } + } + + private fun mergeRecipients( + existingRecipients: List, + additionalRecipients: List, + ): ImmutableList { + val seenDestinations = LinkedHashSet() + + return (existingRecipients + additionalRecipients) + .asSequence() + .filter { recipient -> + seenDestinations.add(recipient.destination) + } + .toImmutableList() + } + + override fun onQueryChanged(query: String) { + updateQueryInUiState(query = query) + + if (query != queryFlow.value) { + savedStateHandle[SEARCH_QUERY_KEY] = query + } + } + + private suspend fun startSearch(query: String) { + applySearchStartedState() + delay(timeMillis = SEARCH_DEBOUNCE_MILLIS) + + val initialSearchResult = resolveInitialSearch(query = query) + updateSearchSession { currentSearchSession -> + currentSearchSession.copy( + effectiveQuery = initialSearchResult.effectiveQuery, + hasCompletedInitialLoad = true, + nextPageOffset = initialSearchResult.page.nextOffset, + ) + } + + applyInitialSearchResult(result = initialSearchResult) + } + + private suspend fun applyPermissionDeniedState(query: String) { + updateSearchSession { currentSearchSession -> + currentSearchSession.copy( + effectiveQuery = query, + nextPageOffset = null, + ) + } + + _uiState.update { currentState -> + currentState.copy( + canLoadMore = false, + contacts = persistentListOf(), + hasContactsPermission = false, + isLoading = false, + isLoadingMore = false, + ) + } + } + + private suspend fun applySearchStartedState() { + val shouldShowInitialLoader = searchSessionMutex.withLock { + !searchSession.hasCompletedInitialLoad + } + + _uiState.update { currentState -> + currentState.copy( + canLoadMore = false, + hasContactsPermission = true, + isLoading = shouldShowInitialLoader, + isLoadingMore = false, + ) + } + } + + private suspend fun resolveInitialSearch(query: String): InitialSearchResult { + val requestedPage = loadRecipientsPage( + query = query, + offset = 0, + ) + + val shouldUseRequestedPage = shouldUseRequestedPage( + query = query, + page = requestedPage, + ) + + if (shouldUseRequestedPage) { + return InitialSearchResult( + effectiveQuery = query, + page = requestedPage, + ) + } + + val defaultPage = loadRecipientsPage( + query = "", + offset = 0, + ) + + return InitialSearchResult( + effectiveQuery = "", + page = defaultPage, + ) + } + + private fun shouldUseRequestedPage( + query: String, + page: ConversationRecipientsPage, + ): Boolean { + return query.isBlank() || page.recipients.isNotEmpty() + } + + private suspend fun loadRecipientsPage( + query: String, + offset: Int, + ): ConversationRecipientsPage { + return conversationRecipientsRepository + .searchRecipients( + query = query, + offset = offset, + ) + .first() + } + + private fun applyInitialSearchResult(result: InitialSearchResult) { + _uiState.update { currentState -> + currentState.copy( + contacts = result.page.recipients, + canLoadMore = result.page.nextOffset != null, + hasContactsPermission = true, + isLoading = false, + isLoadingMore = false, + ) + } + } + + private suspend fun createLoadMoreRequest(): LoadMoreRequest? { + val currentUiState = _uiState.value + + if (currentUiState.isLoading || currentUiState.isLoadingMore) { + return null + } + + if (!currentUiState.hasContactsPermission) { + return null + } + + return searchSessionMutex.withLock { + val nextPageOffset = searchSession.nextPageOffset ?: return@withLock null + + LoadMoreRequest( + effectiveQuery = searchSession.effectiveQuery, + inputQuery = currentUiState.query, + offset = nextPageOffset, + ) + } + } + + private suspend fun loadMore(request: LoadMoreRequest) { + applyLoadMoreStartedState() + + val nextPage = loadRecipientsPage( + query = request.effectiveQuery, + offset = request.offset, + ) + + if (!isLoadMoreRequestCurrent(request = request)) { + applyLoadMoreStoppedState() + return + } + + updateSearchSession { currentSearchSession -> + currentSearchSession.copy( + nextPageOffset = nextPage.nextOffset, + ) + } + + applyLoadMoreResult(page = nextPage) + } + + private fun applyLoadMoreStartedState() { + _uiState.update { currentState -> + currentState.copy( + isLoadingMore = true, + ) + } + } + + private suspend fun isLoadMoreRequestCurrent(request: LoadMoreRequest): Boolean { + val currentEffectiveQuery = searchSessionMutex.withLock { + searchSession.effectiveQuery + } + + return currentEffectiveQuery == request.effectiveQuery && + _uiState.value.query == request.inputQuery + } + + private fun applyLoadMoreStoppedState() { + _uiState.update { currentState -> + currentState.copy( + isLoadingMore = false, + ) + } + } + + private fun applyLoadMoreResult(page: ConversationRecipientsPage) { + _uiState.update { currentState -> + currentState.copy( + contacts = mergeRecipients( + existingRecipients = currentState.contacts, + additionalRecipients = page.recipients, + ), + canLoadMore = page.nextOffset != null, + isLoadingMore = false, + ) + } + } + + private fun updateQueryInUiState(query: String) { + _uiState.update { currentState -> + currentState.copy( + query = query, + ) + } + } + + private suspend fun updateSearchSession( + transform: (RecipientSearchSession) -> RecipientSearchSession, + ) { + searchSessionMutex.withLock { + searchSession = transform(searchSession) + } + } + + private data class InitialSearchResult( + val effectiveQuery: String, + val page: ConversationRecipientsPage, + ) + + private data class LoadMoreRequest( + val effectiveQuery: String, + val inputQuery: String, + val offset: Int, + ) + + private data class RecipientSearchSession( + val effectiveQuery: String, + val hasCompletedInitialLoad: Boolean, + val nextPageOffset: Int?, + ) + + private companion object { + private const val SEARCH_DEBOUNCE_MILLIS = 150L + private const val SEARCH_QUERY_KEY = "search_query" + } +} diff --git a/src/com/android/messaging/ui/conversation/v2/recipientpicker/model/RecipientPickerUiState.kt b/src/com/android/messaging/ui/conversation/v2/recipientpicker/model/RecipientPickerUiState.kt new file mode 100644 index 00000000..85650ba9 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/v2/recipientpicker/model/RecipientPickerUiState.kt @@ -0,0 +1,16 @@ +package com.android.messaging.ui.conversation.v2.recipientpicker.model + +import androidx.compose.runtime.Immutable +import com.android.messaging.data.conversation.model.recipient.ConversationRecipient +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf + +@Immutable +internal data class RecipientPickerUiState( + val query: String = "", + val contacts: ImmutableList = persistentListOf(), + val canLoadMore: Boolean = false, + val hasContactsPermission: Boolean = true, + val isLoading: Boolean = false, + val isLoadingMore: Boolean = false, +) From 534d58ba640b67c8bac0fb10dc43a9417b98f630 Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Thu, 16 Apr 2026 17:30:49 +0300 Subject: [PATCH 34/99] Wire recipient picker into new chat flow --- res/values/strings.xml | 5 + .../ConversationMessageDataDraftMapper.kt | 2 +- .../conversation/v2/ConversationTestTags.kt | 2 + .../v2/entry/ConversationEntryViewModel.kt | 135 ++++- .../ui/conversation/v2/entry/NewChatScreen.kt | 554 +++++++++++++++++- .../v2/entry/model/ConversationEntryEffect.kt | 6 + .../entry/model/ConversationEntryUiState.kt | 3 + .../v2/navigation/ConversationNavGraph.kt | 53 +- .../v2/screen/ConversationViewModel.kt | 2 +- 9 files changed, 746 insertions(+), 16 deletions(-) diff --git a/res/values/strings.xml b/res/values/strings.xml index 7658e458..702458a1 100644 --- a/res/values/strings.xml +++ b/res/values/strings.xml @@ -317,6 +317,11 @@ Tap & hold + + To: + + Type name or phone number + ,\u0020 diff --git a/src/com/android/messaging/data/conversation/mapper/ConversationMessageDataDraftMapper.kt b/src/com/android/messaging/data/conversation/mapper/ConversationMessageDataDraftMapper.kt index 9f0f8e9d..20cbafd9 100644 --- a/src/com/android/messaging/data/conversation/mapper/ConversationMessageDataDraftMapper.kt +++ b/src/com/android/messaging/data/conversation/mapper/ConversationMessageDataDraftMapper.kt @@ -5,8 +5,8 @@ import com.android.messaging.data.conversation.model.draft.ConversationDraftAtta import com.android.messaging.datamodel.data.MessageData import com.android.messaging.datamodel.data.MessagePartData import com.android.messaging.util.LogUtil -import kotlinx.collections.immutable.toImmutableList import javax.inject.Inject +import kotlinx.collections.immutable.toImmutableList internal interface ConversationMessageDataDraftMapper { fun map( diff --git a/src/com/android/messaging/ui/conversation/v2/ConversationTestTags.kt b/src/com/android/messaging/ui/conversation/v2/ConversationTestTags.kt index d0f2edbe..6b0fa73d 100644 --- a/src/com/android/messaging/ui/conversation/v2/ConversationTestTags.kt +++ b/src/com/android/messaging/ui/conversation/v2/ConversationTestTags.kt @@ -10,6 +10,8 @@ internal const val CONVERSATION_ATTACHMENT_PREVIEW_LIST_TEST_TAG = internal const val CONVERSATION_LOADING_INDICATOR_TEST_TAG = "conversation_loading_indicator" internal const val CONVERSATION_MESSAGES_LIST_TEST_TAG = "conversation_messages_list" internal const val CONVERSATION_MEDIA_PICKER_OVERLAY_TEST_TAG = "conversation_media_picker_overlay" +internal const val NEW_CHAT_CONTACT_RESOLVING_INDICATOR_TEST_TAG = + "new_chat_contact_resolving_indicator" internal const val CONVERSATION_SEND_BUTTON_SHAPE_CIRCLE = "circle" internal const val CONVERSATION_SEND_BUTTON_TEST_TAG = "conversation_send_button" internal const val CONVERSATION_TEXT_FIELD_TEST_TAG = "conversation_text_field" diff --git a/src/com/android/messaging/ui/conversation/v2/entry/ConversationEntryViewModel.kt b/src/com/android/messaging/ui/conversation/v2/entry/ConversationEntryViewModel.kt index 2ebfb2f0..4f53052b 100644 --- a/src/com/android/messaging/ui/conversation/v2/entry/ConversationEntryViewModel.kt +++ b/src/com/android/messaging/ui/conversation/v2/entry/ConversationEntryViewModel.kt @@ -2,26 +2,38 @@ package com.android.messaging.ui.conversation.v2.entry import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.android.messaging.R import com.android.messaging.data.conversation.mapper.ConversationMessageDataDraftMapper import com.android.messaging.datamodel.data.MessageData +import com.android.messaging.di.core.MainDispatcher +import com.android.messaging.domain.conversation.usecase.ResolveConversationId +import com.android.messaging.domain.conversation.usecase.model.ResolveConversationIdResult import com.android.messaging.ui.conversation.v2.entry.model.ConversationEntryEffect import com.android.messaging.ui.conversation.v2.entry.model.ConversationEntryLaunchRequest import com.android.messaging.ui.conversation.v2.entry.model.ConversationEntryStartupAttachment import com.android.messaging.ui.conversation.v2.entry.model.ConversationEntryUiState +import com.android.messaging.ui.conversation.v2.navigation.RecipientPickerMode import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow -import javax.inject.Inject +import kotlinx.coroutines.launch internal interface ConversationEntryModel { val effects: Flow val uiState: StateFlow + fun onCreateGroupRequested() fun onLaunchRequest(launchRequest: ConversationEntryLaunchRequest) + fun onNewChatRecipientSelected(destination: String) fun onDraftPayloadConsumed(conversationId: String) fun onStartupAttachmentConsumed(conversationId: String) fun navigateBack() @@ -29,11 +41,17 @@ internal interface ConversationEntryModel { fun showMessage(messageResId: Int) } +internal const val RESOLVING_CONVERSATION_INDICATOR_DELAY_MILLIS = 200L + @HiltViewModel internal class ConversationEntryViewModel @Inject constructor( private val conversationMessageDataDraftMapper: ConversationMessageDataDraftMapper, + private val resolveConversationId: ResolveConversationId, private val savedStateHandle: SavedStateHandle, -) : ViewModel(), ConversationEntryModel { + @param:MainDispatcher + private val mainDispatcher: CoroutineDispatcher, +) : ViewModel(), + ConversationEntryModel { private val _effects = MutableSharedFlow( extraBufferCapacity = 1, @@ -41,11 +59,23 @@ internal class ConversationEntryViewModel @Inject constructor( private val _uiState = MutableStateFlow( value = restoreUiState(), ) + private var resolveConversationJob: Job? = null override val effects = _effects.asSharedFlow() override val uiState = _uiState.asStateFlow() + override fun onCreateGroupRequested() { + cancelConversationResolution() + _effects.tryEmit( + ConversationEntryEffect.NavigateToRecipientPicker( + mode = RecipientPickerMode.CREATE_GROUP, + ), + ) + } + override fun onLaunchRequest(launchRequest: ConversationEntryLaunchRequest) { + cancelConversationResolution() + val processedLaunchGeneration = savedStateHandle.get( PROCESSED_LAUNCH_GENERATION_KEY, ) @@ -68,6 +98,44 @@ internal class ConversationEntryViewModel @Inject constructor( savedStateHandle[PROCESSED_LAUNCH_GENERATION_KEY] = launchRequest.launchGeneration } + override fun onNewChatRecipientSelected(destination: String) { + if (_uiState.value.isResolvingConversation) { + return + } + + resolveConversationJob = viewModelScope.launch(mainDispatcher) { + startConversationResolution(destination = destination) + val showIndicatorJob = launch(mainDispatcher) { + delay(timeMillis = RESOLVING_CONVERSATION_INDICATOR_DELAY_MILLIS) + showConversationResolutionIndicator(destination = destination) + } + + try { + when ( + val resolveConversationIdResult = resolveConversationId( + destinations = listOf(destination), + ) + ) { + is ResolveConversationIdResult.Resolved -> { + navigateToConversation( + conversationId = resolveConversationIdResult.conversationId, + ) + } + + ResolveConversationIdResult.EmptyDestinations, + ResolveConversationIdResult.NotResolved, + -> { + clearConversationResolutionState() + showMessage(messageResId = R.string.conversation_creation_failure) + } + } + } finally { + showIndicatorJob.cancel() + resolveConversationJob = null + } + } + } + override fun onDraftPayloadConsumed(conversationId: String) { val currentUiState = _uiState.value @@ -98,10 +166,20 @@ internal class ConversationEntryViewModel @Inject constructor( } override fun navigateBack() { + cancelConversationResolution() _effects.tryEmit(ConversationEntryEffect.NavigateBack) } override fun navigateToConversation(conversationId: String) { + updateUiState( + _uiState.value.copy( + conversationId = conversationId, + isResolvingConversation = false, + isResolvingConversationIndicatorVisible = false, + resolvingRecipientDestination = null, + ), + ) + _effects.tryEmit( ConversationEntryEffect.NavigateToConversation( conversationId = conversationId, @@ -131,6 +209,8 @@ internal class ConversationEntryViewModel @Inject constructor( return ConversationEntryUiState( launchGeneration = savedStateHandle[LAUNCH_GENERATION_KEY], conversationId = savedStateHandle[CONVERSATION_ID_KEY], + isResolvingConversation = false, + isResolvingConversationIndicatorVisible = false, pendingDraft = pendingDraftData?.let { messageData -> conversationMessageDataDraftMapper.map(messageData = messageData) }, @@ -144,6 +224,57 @@ internal class ConversationEntryViewModel @Inject constructor( else -> null }, + resolvingRecipientDestination = null, + ) + } + + private fun clearConversationResolutionState() { + updateUiState( + _uiState.value.copy( + isResolvingConversation = false, + isResolvingConversationIndicatorVisible = false, + resolvingRecipientDestination = null, + ), + ) + } + + private fun cancelConversationResolution() { + val currentResolveConversationJob = resolveConversationJob + resolveConversationJob = null + currentResolveConversationJob?.cancel() + + if (_uiState.value.isResolvingConversation || + _uiState.value.isResolvingConversationIndicatorVisible || + _uiState.value.resolvingRecipientDestination != null + ) { + clearConversationResolutionState() + } + } + + private fun showConversationResolutionIndicator(destination: String) { + val currentUiState = _uiState.value + + if (!currentUiState.isResolvingConversation || + currentUiState.resolvingRecipientDestination != destination || + currentUiState.isResolvingConversationIndicatorVisible + ) { + return + } + + updateUiState( + currentUiState.copy( + isResolvingConversationIndicatorVisible = true, + ), + ) + } + + private fun startConversationResolution(destination: String) { + updateUiState( + _uiState.value.copy( + isResolvingConversation = true, + isResolvingConversationIndicatorVisible = false, + resolvingRecipientDestination = destination, + ), ) } diff --git a/src/com/android/messaging/ui/conversation/v2/entry/NewChatScreen.kt b/src/com/android/messaging/ui/conversation/v2/entry/NewChatScreen.kt index 143f573b..ef39c00a 100644 --- a/src/com/android/messaging/ui/conversation/v2/entry/NewChatScreen.kt +++ b/src/com/android/messaging/ui/conversation/v2/entry/NewChatScreen.kt @@ -2,42 +2,590 @@ package com.android.messaging.ui.conversation.v2.entry +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.ArrowBack +import androidx.compose.material.icons.rounded.Group +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldDefaults import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import coil3.compose.AsyncImage import com.android.messaging.R +import com.android.messaging.data.conversation.model.recipient.ConversationRecipient +import com.android.messaging.ui.conversation.v2.NEW_CHAT_CONTACT_RESOLVING_INDICATOR_TEST_TAG +import com.android.messaging.ui.conversation.v2.recipientpicker.RecipientPickerModel +import com.android.messaging.ui.conversation.v2.recipientpicker.RecipientPickerViewModel +import com.android.messaging.ui.conversation.v2.recipientpicker.model.RecipientPickerUiState +import com.android.messaging.ui.core.AppTheme + +private val CONTACT_CORNER_RADIUS = 18.dp +private val CONTACT_MIDDLE_CORNER_RADIUS = 2.dp + +private val SearchFieldShape = RoundedCornerShape(size = 22.dp) + +private val TopContactShape = RoundedCornerShape( + topStart = CONTACT_CORNER_RADIUS, + topEnd = CONTACT_CORNER_RADIUS, + bottomStart = CONTACT_MIDDLE_CORNER_RADIUS, + bottomEnd = CONTACT_MIDDLE_CORNER_RADIUS, +) +private val BottomContactShape = RoundedCornerShape( + topStart = CONTACT_MIDDLE_CORNER_RADIUS, + topEnd = CONTACT_MIDDLE_CORNER_RADIUS, + bottomStart = CONTACT_CORNER_RADIUS, + bottomEnd = CONTACT_CORNER_RADIUS, +) +private val MiddleContactShape = RoundedCornerShape(size = CONTACT_MIDDLE_CORNER_RADIUS) +private val SingleContactShape = RoundedCornerShape(size = CONTACT_CORNER_RADIUS) + +private const val CONTACTS_LOAD_MORE_THRESHOLD = 10 +private const val NEW_CHAT_CONTACT_CONTENT_TYPE = "new_chat_contact" @Composable internal fun NewChatScreen( modifier: Modifier = Modifier, + isResolvingConversation: Boolean = false, + isResolvingConversationIndicatorVisible: Boolean = false, + onContactClick: (String) -> Unit = {}, + onCreateGroupClick: () -> Unit = {}, + onNavigateBack: () -> Unit = {}, + pickerModel: RecipientPickerModel = hiltViewModel(), + resolvingRecipientDestination: String? = null, ) { + val uiState by pickerModel.uiState.collectAsStateWithLifecycle() + val screenContainerColor = MaterialTheme.colorScheme.surfaceVariant + Scaffold( modifier = modifier, + containerColor = screenContainerColor, topBar = { TopAppBar( + colors = TopAppBarDefaults.topAppBarColors( + containerColor = screenContainerColor, + navigationIconContentColor = MaterialTheme.colorScheme.onSurface, + titleContentColor = MaterialTheme.colorScheme.onSurface, + actionIconContentColor = MaterialTheme.colorScheme.onSurfaceVariant, + ), + navigationIcon = { + IconButton( + onClick = onNavigateBack, + ) { + Icon( + imageVector = Icons.AutoMirrored.Rounded.ArrowBack, + contentDescription = stringResource(id = R.string.back), + ) + } + }, title = { Text(text = stringResource(id = R.string.start_new_conversation)) }, ) }, ) { contentPadding -> - Box( + NewChatScreenContent( modifier = Modifier .fillMaxSize() .padding(paddingValues = contentPadding), - contentAlignment = Alignment.Center, + uiState = uiState, + isResolvingConversation = isResolvingConversation, + isResolvingConversationIndicatorVisible = isResolvingConversationIndicatorVisible, + onContactClick = onContactClick, + onCreateGroupClick = onCreateGroupClick, + onLoadMore = pickerModel::onLoadMore, + onQueryChanged = pickerModel::onQueryChanged, + resolvingRecipientDestination = resolvingRecipientDestination, + ) + } +} + +@Composable +private fun NewChatScreenContent( + uiState: RecipientPickerUiState, + modifier: Modifier = Modifier, + isResolvingConversation: Boolean = false, + isResolvingConversationIndicatorVisible: Boolean = false, + onContactClick: (String) -> Unit, + onCreateGroupClick: () -> Unit, + onLoadMore: () -> Unit, + onQueryChanged: (String) -> Unit, + resolvingRecipientDestination: String? = null, +) { + Surface( + modifier = modifier, + color = MaterialTheme.colorScheme.surfaceVariant, + ) { + NewChatScreenBody( + uiState = uiState, + isResolvingConversation = isResolvingConversation, + isResolvingConversationIndicatorVisible = isResolvingConversationIndicatorVisible, + onContactClick = onContactClick, + onCreateGroupClick = onCreateGroupClick, + onLoadMore = onLoadMore, + onQueryChanged = onQueryChanged, + resolvingRecipientDestination = resolvingRecipientDestination, + ) + } +} + +@Composable +private fun NewChatScreenBody( + uiState: RecipientPickerUiState, + isResolvingConversation: Boolean, + isResolvingConversationIndicatorVisible: Boolean, + onContactClick: (String) -> Unit, + onCreateGroupClick: () -> Unit, + onLoadMore: () -> Unit, + onQueryChanged: (String) -> Unit, + resolvingRecipientDestination: String?, +) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 16.dp), + ) { + Spacer(modifier = Modifier.height(height = 16.dp)) + + NewChatQueryField( + query = uiState.query, + enabled = !isResolvingConversation, + onQueryChanged = onQueryChanged, + ) + + Spacer(modifier = Modifier.height(height = 12.dp)) + + NewChatContactsContent( + modifier = Modifier.fillMaxSize(), + uiState = uiState, + contactSelectionEnabled = !isResolvingConversation, + isResolvingConversationIndicatorVisible = isResolvingConversationIndicatorVisible, + onContactClick = onContactClick, + onCreateGroupClick = onCreateGroupClick, + onLoadMore = onLoadMore, + resolvingRecipientDestination = resolvingRecipientDestination, + ) + } +} + +@Composable +private fun NewChatQueryField( + query: String, + enabled: Boolean, + onQueryChanged: (String) -> Unit, +) { + val colorScheme = MaterialTheme.colorScheme + + TextField( + modifier = Modifier + .fillMaxWidth(), + value = query, + onValueChange = onQueryChanged, + enabled = enabled, + singleLine = true, + shape = SearchFieldShape, + colors = TextFieldDefaults.colors( + focusedContainerColor = colorScheme.surface, + unfocusedContainerColor = colorScheme.surface, + disabledContainerColor = colorScheme.surface, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + disabledIndicatorColor = Color.Transparent, + focusedTextColor = colorScheme.onSurface, + unfocusedTextColor = colorScheme.onSurface, + disabledTextColor = colorScheme.onSurface, + focusedPlaceholderColor = colorScheme.onSurfaceVariant, + unfocusedPlaceholderColor = colorScheme.onSurfaceVariant, + disabledPlaceholderColor = colorScheme.onSurfaceVariant, + focusedPrefixColor = colorScheme.onSurfaceVariant, + unfocusedPrefixColor = colorScheme.onSurfaceVariant, + disabledPrefixColor = colorScheme.onSurfaceVariant, + ), + prefix = { + Text( + modifier = Modifier + .padding(end = 12.dp), + text = stringResource(id = R.string.new_chat_recipient_prefix), + style = MaterialTheme.typography.bodyLarge, + ) + }, + placeholder = { + Text( + text = stringResource(id = R.string.new_chat_query_hint), + ) + }, + ) +} + +@Composable +private fun NewChatContactsContent( + modifier: Modifier = Modifier, + uiState: RecipientPickerUiState, + contactSelectionEnabled: Boolean, + isResolvingConversationIndicatorVisible: Boolean, + onContactClick: (String) -> Unit, + onCreateGroupClick: () -> Unit, + onLoadMore: () -> Unit, + resolvingRecipientDestination: String?, +) { + val contacts = uiState.contacts + val lastContactIndex = contacts.lastIndex + val listState = rememberLazyListState() + + LaunchedEffect( + listState, + uiState.canLoadMore, + uiState.isLoading, + uiState.isLoadingMore, + contacts.size, + ) { + snapshotFlow { + val lastVisibleIndex = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1 + lastVisibleIndex >= lastContactIndex - CONTACTS_LOAD_MORE_THRESHOLD + }.collect { shouldLoadMore -> + val isLoading = uiState.isLoading || uiState.isLoadingMore + if (shouldLoadMore && uiState.canLoadMore && !isLoading) { + onLoadMore() + } + } + } + + LazyColumn( + modifier = modifier, + state = listState, + contentPadding = PaddingValues(bottom = 16.dp), + ) { + item { + NewGroupButton( + modifier = Modifier + .fillMaxWidth(), + enabled = true, + onClick = onCreateGroupClick, + ) + } + + item { + Spacer(modifier = Modifier.height(height = 12.dp)) + } + + when { + uiState.isLoading -> { + item { + NewChatLoadingState() + } + } + + uiState.contacts.isEmpty() || !uiState.hasContactsPermission -> { + item { + NewChatEmptyState() + } + } + + else -> { + itemsIndexed( + items = contacts, + key = { _, contact -> contact.id }, + contentType = { _, _ -> + NEW_CHAT_CONTACT_CONTENT_TYPE + }, + ) { index, contact -> + val bottomPadding = when { + index == lastContactIndex -> 0.dp + else -> 2.dp + } + + NewChatContactRow( + modifier = Modifier + .padding(bottom = bottomPadding), + contact = contact, + shape = newChatContactRowShape( + index = index, + totalCount = contacts.size, + ), + enabled = contactSelectionEnabled, + onContactClick = onContactClick, + showResolvingIndicator = isResolvingConversationIndicatorVisible && + resolvingRecipientDestination == contact.destination, + ) + } + } + } + + if (uiState.isLoadingMore) { + item { + NewChatLoadingMoreState() + } + } + } +} + +@Composable +private fun NewChatLoadingState() { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 32.dp), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } +} + +@Composable +private fun NewChatLoadingMoreState() { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 16.dp), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator( + modifier = Modifier.size(size = 20.dp), + strokeWidth = 2.dp, + ) + } +} + +@Composable +private fun NewChatEmptyState() { + Text( + text = stringResource(id = R.string.contact_list_empty_text), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(vertical = 24.dp, horizontal = 4.dp), + ) +} + +@Composable +private fun NewGroupButton( + modifier: Modifier = Modifier, + enabled: Boolean, + onClick: () -> Unit, +) { + val hapticFeedback = LocalHapticFeedback.current + + FilledTonalButton( + modifier = modifier, + onClick = { + hapticFeedback.performHapticFeedback(HapticFeedbackType.ContextClick) + onClick() + }, + enabled = enabled, + shape = RoundedCornerShape(size = 18.dp), + colors = ButtonDefaults.filledTonalButtonColors( + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary, + disabledContainerColor = MaterialTheme.colorScheme.primary.copy( + alpha = 0.5f, + ), + disabledContentColor = MaterialTheme.colorScheme.onPrimaryContainer.copy( + alpha = 0.5f, + ), + ), + ) { + Icon( + imageVector = Icons.Rounded.Group, + contentDescription = null, + ) + Spacer(modifier = Modifier.size(size = 8.dp)) + Text(text = stringResource(id = R.string.conversation_new_group)) + } +} + +@Composable +private fun NewChatContactRow( + modifier: Modifier = Modifier, + contact: ConversationRecipient, + shape: RoundedCornerShape, + enabled: Boolean, + onContactClick: (String) -> Unit, + showResolvingIndicator: Boolean, +) { + val hapticFeedback = LocalHapticFeedback.current + + Row( + modifier = Modifier + .then(other = modifier) + .fillMaxWidth() + .background( + color = MaterialTheme.colorScheme.background, + shape = shape, + ) + .clickable( + enabled = enabled, + onClick = { + hapticFeedback.performHapticFeedback(HapticFeedbackType.ContextClick) + onContactClick(contact.destination) + }, + ) + .padding(horizontal = 16.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + NewChatContactAvatar(contact = contact) + + Column( + modifier = Modifier + .padding(start = 14.dp) + .weight(weight = 1f), + verticalArrangement = Arrangement.spacedBy(space = 2.dp), ) { Text( - text = "", + text = contact.displayName, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.bodyLarge, ) + + contact.secondaryText?.let { secondaryText -> + Text( + text = secondaryText, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } } + + if (showResolvingIndicator) { + CircularProgressIndicator( + modifier = Modifier + .size(size = 20.dp) + .testTag(NEW_CHAT_CONTACT_RESOLVING_INDICATOR_TEST_TAG), + strokeWidth = 2.dp, + ) + } + } +} + +private fun newChatContactRowShape( + index: Int, + totalCount: Int, +): RoundedCornerShape { + return when { + totalCount <= 1 -> SingleContactShape + index == 0 -> TopContactShape + index == totalCount - 1 -> BottomContactShape + else -> MiddleContactShape + } +} + +@Composable +private fun NewChatContactAvatar( + contact: ConversationRecipient, +) { + return when { + contact.photoUri == null -> { + NewChatContactTextAvatar( + contact = contact, + ) + } + else -> { + AsyncImage( + model = contact.photoUri, + contentDescription = contact.displayName, + modifier = Modifier + .size(size = 40.dp) + .clip(shape = CircleShape), + ) + } + } +} + +@Composable +private fun NewChatContactTextAvatar( + modifier: Modifier = Modifier, + contact: ConversationRecipient, +) { + val label = remember(contact.displayName, contact.destination) { + contactAvatarLabel(contact = contact) + } + + Box( + modifier = modifier + .size(size = 40.dp) + .background( + color = MaterialTheme.colorScheme.secondaryContainer, + shape = CircleShape, + ), + contentAlignment = Alignment.Center, + ) { + Text( + text = label, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSecondaryContainer, + ) + } +} + +private fun contactAvatarLabel(contact: ConversationRecipient): String { + val labelSource = contact.displayName.ifBlank { contact.destination } + val firstCharacter = labelSource.firstOrNull() ?: '?' + + return firstCharacter.uppercaseChar().toString() +} + +@Composable +private fun NewChatScreenPreviewContent( + uiState: RecipientPickerUiState, + isResolvingConversation: Boolean = false, + isResolvingConversationIndicatorVisible: Boolean = false, + resolvingRecipientDestination: String? = null, +) { + AppTheme { + NewChatScreenContent( + modifier = Modifier.fillMaxSize(), + uiState = uiState, + isResolvingConversation = isResolvingConversation, + isResolvingConversationIndicatorVisible = isResolvingConversationIndicatorVisible, + onContactClick = {}, + onCreateGroupClick = {}, + onLoadMore = {}, + onQueryChanged = {}, + resolvingRecipientDestination = resolvingRecipientDestination, + ) } } diff --git a/src/com/android/messaging/ui/conversation/v2/entry/model/ConversationEntryEffect.kt b/src/com/android/messaging/ui/conversation/v2/entry/model/ConversationEntryEffect.kt index 3dba455f..d3f2d852 100644 --- a/src/com/android/messaging/ui/conversation/v2/entry/model/ConversationEntryEffect.kt +++ b/src/com/android/messaging/ui/conversation/v2/entry/model/ConversationEntryEffect.kt @@ -1,11 +1,17 @@ package com.android.messaging.ui.conversation.v2.entry.model +import com.android.messaging.ui.conversation.v2.navigation.RecipientPickerMode + internal sealed interface ConversationEntryEffect { data class NavigateToConversation( val conversationId: String, ) : ConversationEntryEffect + data class NavigateToRecipientPicker( + val mode: RecipientPickerMode, + ) : ConversationEntryEffect + data object NavigateBack : ConversationEntryEffect data class ShowMessage( diff --git a/src/com/android/messaging/ui/conversation/v2/entry/model/ConversationEntryUiState.kt b/src/com/android/messaging/ui/conversation/v2/entry/model/ConversationEntryUiState.kt index 4911398a..5759e8ab 100644 --- a/src/com/android/messaging/ui/conversation/v2/entry/model/ConversationEntryUiState.kt +++ b/src/com/android/messaging/ui/conversation/v2/entry/model/ConversationEntryUiState.kt @@ -7,8 +7,11 @@ import com.android.messaging.data.conversation.model.draft.ConversationDraft internal data class ConversationEntryUiState( val launchGeneration: Int? = null, val conversationId: String? = null, + val isResolvingConversation: Boolean = false, + val isResolvingConversationIndicatorVisible: Boolean = false, val pendingDraft: ConversationDraft? = null, val pendingStartupAttachment: ConversationEntryStartupAttachment? = null, + val resolvingRecipientDestination: String? = null, ) @Immutable diff --git a/src/com/android/messaging/ui/conversation/v2/navigation/ConversationNavGraph.kt b/src/com/android/messaging/ui/conversation/v2/navigation/ConversationNavGraph.kt index 46073630..e9a21629 100644 --- a/src/com/android/messaging/ui/conversation/v2/navigation/ConversationNavGraph.kt +++ b/src/com/android/messaging/ui/conversation/v2/navigation/ConversationNavGraph.kt @@ -4,6 +4,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Modifier import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -34,6 +35,9 @@ internal fun ConversationNavGraph( ) { val entryUiState by entryModel.uiState.collectAsStateWithLifecycle() val backStack = rememberNavBackStack(initialNavKey(launchRequest = launchRequest)) + val latestEntryModel = rememberUpdatedState(newValue = entryModel) + val latestEntryUiState = rememberUpdatedState(newValue = entryUiState) + val latestOnFinish = rememberUpdatedState(newValue = onFinish) val entryDecorators = listOf( rememberSaveableStateHolderNavEntryDecorator(), @@ -41,35 +45,38 @@ internal fun ConversationNavGraph( ) val entryProvider = remember( - entryUiState, - onFinish, + backStack, ) { entryProvider { entry { navKey -> + val currentEntryUiState = latestEntryUiState.value + val currentEntryModel = latestEntryModel.value + val currentOnFinish = latestOnFinish.value + ConversationScreen( conversationId = navKey.conversationId, - launchGeneration = entryUiState.launchGeneration, + launchGeneration = currentEntryUiState.launchGeneration, onNavigateBack = { popBackStackOrFinish( backStack = backStack, - onFinish = onFinish, + onFinish = currentOnFinish, ) }, pendingDraft = pendingDraftForConversation( - entryUiState = entryUiState, + entryUiState = currentEntryUiState, conversationId = navKey.conversationId, ), pendingStartupAttachment = pendingStartupAttachmentForConversation( - entryUiState = entryUiState, + entryUiState = currentEntryUiState, conversationId = navKey.conversationId, ), onPendingDraftConsumed = { - entryModel.onDraftPayloadConsumed( + currentEntryModel.onDraftPayloadConsumed( conversationId = navKey.conversationId, ) }, onPendingStartupAttachmentConsumed = { - entryModel.onStartupAttachmentConsumed( + currentEntryModel.onStartupAttachmentConsumed( conversationId = navKey.conversationId, ) }, @@ -77,7 +84,19 @@ internal fun ConversationNavGraph( } entry { - NewChatScreen() + val currentEntryUiState = latestEntryUiState.value + val currentEntryModel = latestEntryModel.value + + NewChatScreen( + isResolvingConversation = currentEntryUiState.isResolvingConversation, + isResolvingConversationIndicatorVisible = currentEntryUiState + .isResolvingConversationIndicatorVisible, + onContactClick = currentEntryModel::onNewChatRecipientSelected, + onCreateGroupClick = currentEntryModel::onCreateGroupRequested, + onNavigateBack = currentEntryModel::navigateBack, + resolvingRecipientDestination = currentEntryUiState + .resolvingRecipientDestination, + ) } entry { navKey -> @@ -195,6 +214,13 @@ private fun handleEntryEffect( ) } + is ConversationEntryEffect.NavigateToRecipientPicker -> { + navigateToRecipientPicker( + backStack = backStack, + mode = effect.mode, + ) + } + is ConversationEntryEffect.ShowMessage -> { UiUtils.showToastAtBottom(effect.messageResId) } @@ -209,3 +235,12 @@ private fun navigateToConversation( .takeIf { it != backStack.lastOrNull() } ?.let(backStack::add) } + +private fun navigateToRecipientPicker( + backStack: MutableList, + mode: RecipientPickerMode, +) { + RecipientPickerNavKey(mode = mode) + .takeIf { it != backStack.lastOrNull() } + ?.let(backStack::add) +} diff --git a/src/com/android/messaging/ui/conversation/v2/screen/ConversationViewModel.kt b/src/com/android/messaging/ui/conversation/v2/screen/ConversationViewModel.kt index 718af270..fd885e62 100644 --- a/src/com/android/messaging/ui/conversation/v2/screen/ConversationViewModel.kt +++ b/src/com/android/messaging/ui/conversation/v2/screen/ConversationViewModel.kt @@ -20,6 +20,7 @@ import com.android.messaging.ui.conversation.v2.screen.model.ConversationMediaPi import com.android.messaging.ui.conversation.v2.screen.model.ConversationScreenEffect import com.android.messaging.ui.conversation.v2.screen.model.ConversationScreenScaffoldUiState import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow @@ -29,7 +30,6 @@ import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch -import javax.inject.Inject internal interface ConversationScreenModel { val effects: Flow From 8dd4d4a88b23d2d99f8effba80f204d5b99cbf02 Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Thu, 16 Apr 2026 20:52:52 +0300 Subject: [PATCH 35/99] Add inline group creation flow to new chat --- .../conversation/ConversationBindsModule.kt | 8 + .../IsConversationRecipientLimitExceeded.kt | 16 + .../conversation/v2/ConversationTestTags.kt | 6 + .../v2/entry/ConversationEntryViewModel.kt | 303 +++++++-- .../ui/conversation/v2/entry/NewChatScreen.kt | 628 +++++++++++++++--- .../entry/model/ConversationEntryUiState.kt | 4 + .../v2/navigation/ConversationNavGraph.kt | 55 +- 7 files changed, 869 insertions(+), 151 deletions(-) create mode 100644 src/com/android/messaging/domain/conversation/usecase/IsConversationRecipientLimitExceeded.kt diff --git a/src/com/android/messaging/di/conversation/ConversationBindsModule.kt b/src/com/android/messaging/di/conversation/ConversationBindsModule.kt index 377423a9..71c4d2dd 100644 --- a/src/com/android/messaging/di/conversation/ConversationBindsModule.kt +++ b/src/com/android/messaging/di/conversation/ConversationBindsModule.kt @@ -18,6 +18,8 @@ import com.android.messaging.data.media.repository.ConversationMediaRepository import com.android.messaging.data.media.repository.ConversationMediaRepositoryImpl import com.android.messaging.domain.contacts.usecase.IsReadContactsPermissionGranted import com.android.messaging.domain.contacts.usecase.IsReadContactsPermissionGrantedImpl +import com.android.messaging.domain.conversation.usecase.IsConversationRecipientLimitExceeded +import com.android.messaging.domain.conversation.usecase.IsConversationRecipientLimitExceededImpl import com.android.messaging.domain.conversation.usecase.ResolveConversationId import com.android.messaging.domain.conversation.usecase.ResolveConversationIdImpl import com.android.messaging.domain.conversation.usecase.SendConversationDraft @@ -88,6 +90,12 @@ internal abstract class ConversationBindsModule { impl: ResolveConversationIdImpl, ): ResolveConversationId + @Binds + @Reusable + abstract fun bindIsConversationRecipientLimitExceeded( + impl: IsConversationRecipientLimitExceededImpl, + ): IsConversationRecipientLimitExceeded + @Binds @Reusable abstract fun bindConversationsRepository( diff --git a/src/com/android/messaging/domain/conversation/usecase/IsConversationRecipientLimitExceeded.kt b/src/com/android/messaging/domain/conversation/usecase/IsConversationRecipientLimitExceeded.kt new file mode 100644 index 00000000..ee433387 --- /dev/null +++ b/src/com/android/messaging/domain/conversation/usecase/IsConversationRecipientLimitExceeded.kt @@ -0,0 +1,16 @@ +package com.android.messaging.domain.conversation.usecase + +import com.android.messaging.datamodel.data.ContactPickerData +import javax.inject.Inject + +internal fun interface IsConversationRecipientLimitExceeded { + operator fun invoke(participantCount: Int): Boolean +} + +internal class IsConversationRecipientLimitExceededImpl @Inject constructor() : + IsConversationRecipientLimitExceeded { + + override operator fun invoke(participantCount: Int): Boolean { + return ContactPickerData.isTooManyParticipants(participantCount) + } +} diff --git a/src/com/android/messaging/ui/conversation/v2/ConversationTestTags.kt b/src/com/android/messaging/ui/conversation/v2/ConversationTestTags.kt index 6b0fa73d..9d61891a 100644 --- a/src/com/android/messaging/ui/conversation/v2/ConversationTestTags.kt +++ b/src/com/android/messaging/ui/conversation/v2/ConversationTestTags.kt @@ -10,6 +10,8 @@ internal const val CONVERSATION_ATTACHMENT_PREVIEW_LIST_TEST_TAG = internal const val CONVERSATION_LOADING_INDICATOR_TEST_TAG = "conversation_loading_indicator" internal const val CONVERSATION_MESSAGES_LIST_TEST_TAG = "conversation_messages_list" internal const val CONVERSATION_MEDIA_PICKER_OVERLAY_TEST_TAG = "conversation_media_picker_overlay" +internal const val NEW_CHAT_CREATE_GROUP_NEXT_BUTTON_TEST_TAG = + "new_chat_create_group_next_button" internal const val NEW_CHAT_CONTACT_RESOLVING_INDICATOR_TEST_TAG = "new_chat_contact_resolving_indicator" internal const val CONVERSATION_SEND_BUTTON_SHAPE_CIRCLE = "circle" @@ -30,6 +32,10 @@ internal fun conversationAttachmentPreviewRemoveButtonTestTag( return "conversation_attachment_preview_remove_button_$attachmentKey" } +internal fun newChatContactRowTestTag(contactId: String): String { + return "new_chat_contact_row_$contactId" +} + internal val conversationShapeSemanticsKey = SemanticsPropertyKey( name = "conversation_shape", ) diff --git a/src/com/android/messaging/ui/conversation/v2/entry/ConversationEntryViewModel.kt b/src/com/android/messaging/ui/conversation/v2/entry/ConversationEntryViewModel.kt index 4f53052b..d5a0eff8 100644 --- a/src/com/android/messaging/ui/conversation/v2/entry/ConversationEntryViewModel.kt +++ b/src/com/android/messaging/ui/conversation/v2/entry/ConversationEntryViewModel.kt @@ -7,16 +7,19 @@ import com.android.messaging.R import com.android.messaging.data.conversation.mapper.ConversationMessageDataDraftMapper import com.android.messaging.datamodel.data.MessageData import com.android.messaging.di.core.MainDispatcher +import com.android.messaging.domain.conversation.usecase.IsConversationRecipientLimitExceeded import com.android.messaging.domain.conversation.usecase.ResolveConversationId import com.android.messaging.domain.conversation.usecase.model.ResolveConversationIdResult import com.android.messaging.ui.conversation.v2.entry.model.ConversationEntryEffect import com.android.messaging.ui.conversation.v2.entry.model.ConversationEntryLaunchRequest import com.android.messaging.ui.conversation.v2.entry.model.ConversationEntryStartupAttachment import com.android.messaging.ui.conversation.v2.entry.model.ConversationEntryUiState -import com.android.messaging.ui.conversation.v2.navigation.RecipientPickerMode import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow @@ -32,12 +35,22 @@ internal interface ConversationEntryModel { val uiState: StateFlow fun onCreateGroupRequested() + fun onCreateGroupCanceled() + fun onCreateGroupRecipientClicked(destination: String) + fun onCreateGroupConfirmed() + fun onLaunchRequest(launchRequest: ConversationEntryLaunchRequest) + + fun onNewChatRecipientLongPressed(destination: String) fun onNewChatRecipientSelected(destination: String) + fun onDraftPayloadConsumed(conversationId: String) + fun onStartupAttachmentConsumed(conversationId: String) + fun navigateBack() fun navigateToConversation(conversationId: String) + fun showMessage(messageResId: Int) } @@ -46,6 +59,7 @@ internal const val RESOLVING_CONVERSATION_INDICATOR_DELAY_MILLIS = 200L @HiltViewModel internal class ConversationEntryViewModel @Inject constructor( private val conversationMessageDataDraftMapper: ConversationMessageDataDraftMapper, + private val isConversationRecipientLimitExceeded: IsConversationRecipientLimitExceeded, private val resolveConversationId: ResolveConversationId, private val savedStateHandle: SavedStateHandle, @param:MainDispatcher @@ -65,15 +79,113 @@ internal class ConversationEntryViewModel @Inject constructor( override val uiState = _uiState.asStateFlow() override fun onCreateGroupRequested() { + // Re-entering group creation should also abandon any in-flight resolution. cancelConversationResolution() - _effects.tryEmit( - ConversationEntryEffect.NavigateToRecipientPicker( - mode = RecipientPickerMode.CREATE_GROUP, + val currentUiState = _uiState.value + + if (currentUiState.isCreatingGroup) { + return + } + + updateUiState( + currentUiState.copy( + isCreatingGroup = true, + selectedGroupRecipientDestinations = persistentListOf(), + ), + ) + } + + override fun onCreateGroupCanceled() { + cancelConversationResolution() + val currentUiState = _uiState.value + + val hasGroupStateToClear = currentUiState.isCreatingGroup || + currentUiState.selectedGroupRecipientDestinations.isNotEmpty() + + if (!hasGroupStateToClear) { + return + } + + updateUiState( + currentUiState.copy( + isCreatingGroup = false, + selectedGroupRecipientDestinations = persistentListOf(), + ), + ) + } + + override fun onCreateGroupRecipientClicked(destination: String) { + val state = editableGroupStateOrNull() ?: return + val current = state.selectedGroupRecipientDestinations + val trimmed = destination.trim() + + val updatedDestinations = when { + trimmed.isEmpty() -> { + return + } + + trimmed in current -> { + current - trimmed + } + + canAcceptRecipientCount(count = current.size + 1) -> { + current + trimmed + } + + else -> { + return + } + } + + updateUiState( + state.copy( + selectedGroupRecipientDestinations = updatedDestinations.toImmutableList(), ), ) } + override fun onCreateGroupConfirmed() { + val state = editableGroupStateOrNull() ?: return + val destinations = state.selectedGroupRecipientDestinations + + val isSelectionValid = destinations.isNotEmpty() && + canAcceptRecipientCount(count = destinations.size) + + if (isSelectionValid) { + resolveConversation( + destinations = destinations, + resolvingRecipientDestination = null, + ) + } + } + + override fun onNewChatRecipientLongPressed(destination: String) { + val state = _uiState.value + + if (state.isResolvingConversation) { + return + } + + if (state.isCreatingGroup) { + onCreateGroupRecipientClicked(destination = destination) + return + } + + val trimmed = destination.trim() + val canSeedGroup = trimmed.isNotEmpty() && canAcceptRecipientCount(count = 1) + + if (canSeedGroup) { + updateUiState( + state.copy( + isCreatingGroup = true, + selectedGroupRecipientDestinations = persistentListOf(trimmed), + ), + ) + } + } + override fun onLaunchRequest(launchRequest: ConversationEntryLaunchRequest) { + // Each new launch should supersede any in-flight resolution from the previous one. cancelConversationResolution() val processedLaunchGeneration = savedStateHandle.get( @@ -91,7 +203,10 @@ internal class ConversationEntryViewModel @Inject constructor( pendingDraft = launchRequest.draftData?.let { messageData -> conversationMessageDataDraftMapper.map(messageData = messageData) }, - pendingStartupAttachment = launchRequest.toStartupAttachmentOrNull(), + pendingStartupAttachment = buildStartupAttachmentOrNull( + contentUri = launchRequest.startupAttachmentUri, + contentType = launchRequest.startupAttachmentType, + ), ), ) savedStateHandle[PENDING_DRAFT_DATA_KEY] = launchRequest.draftData @@ -99,41 +214,16 @@ internal class ConversationEntryViewModel @Inject constructor( } override fun onNewChatRecipientSelected(destination: String) { - if (_uiState.value.isResolvingConversation) { + val currentUiState = _uiState.value + + if (currentUiState.isResolvingConversation || currentUiState.isCreatingGroup) { return } - resolveConversationJob = viewModelScope.launch(mainDispatcher) { - startConversationResolution(destination = destination) - val showIndicatorJob = launch(mainDispatcher) { - delay(timeMillis = RESOLVING_CONVERSATION_INDICATOR_DELAY_MILLIS) - showConversationResolutionIndicator(destination = destination) - } - - try { - when ( - val resolveConversationIdResult = resolveConversationId( - destinations = listOf(destination), - ) - ) { - is ResolveConversationIdResult.Resolved -> { - navigateToConversation( - conversationId = resolveConversationIdResult.conversationId, - ) - } - - ResolveConversationIdResult.EmptyDestinations, - ResolveConversationIdResult.NotResolved, - -> { - clearConversationResolutionState() - showMessage(messageResId = R.string.conversation_creation_failure) - } - } - } finally { - showIndicatorJob.cancel() - resolveConversationJob = null - } - } + resolveConversation( + destinations = listOf(destination), + resolvingRecipientDestination = destination, + ) } override fun onDraftPayloadConsumed(conversationId: String) { @@ -174,9 +264,11 @@ internal class ConversationEntryViewModel @Inject constructor( updateUiState( _uiState.value.copy( conversationId = conversationId, + isCreatingGroup = false, isResolvingConversation = false, isResolvingConversationIndicatorVisible = false, resolvingRecipientDestination = null, + selectedGroupRecipientDestinations = persistentListOf(), ), ) @@ -209,22 +301,21 @@ internal class ConversationEntryViewModel @Inject constructor( return ConversationEntryUiState( launchGeneration = savedStateHandle[LAUNCH_GENERATION_KEY], conversationId = savedStateHandle[CONVERSATION_ID_KEY], + isCreatingGroup = savedStateHandle[IS_CREATING_GROUP_KEY] ?: false, isResolvingConversation = false, isResolvingConversationIndicatorVisible = false, pendingDraft = pendingDraftData?.let { messageData -> conversationMessageDataDraftMapper.map(messageData = messageData) }, - pendingStartupAttachment = when { - startupAttachmentUri != null && startupAttachmentType != null -> { - ConversationEntryStartupAttachment( - contentType = startupAttachmentType, - contentUri = startupAttachmentUri, - ) - } - - else -> null - }, + pendingStartupAttachment = buildStartupAttachmentOrNull( + contentUri = startupAttachmentUri, + contentType = startupAttachmentType, + ), resolvingRecipientDestination = null, + selectedGroupRecipientDestinations = savedStateHandle + .get>(SELECTED_GROUP_RECIPIENT_DESTINATIONS_KEY) + ?.toImmutableList() + ?: persistentListOf(), ) } @@ -238,51 +329,109 @@ internal class ConversationEntryViewModel @Inject constructor( ) } + private fun editableGroupStateOrNull(): ConversationEntryUiState? { + return _uiState.value.takeIf { state -> + state.isCreatingGroup && !state.isResolvingConversation + } + } + + private fun canAcceptRecipientCount(count: Int): Boolean { + if (isConversationRecipientLimitExceeded(count)) { + showMessage(messageResId = R.string.too_many_participants) + return false + } + + return true + } + private fun cancelConversationResolution() { val currentResolveConversationJob = resolveConversationJob + val currentUiState = _uiState.value + resolveConversationJob = null currentResolveConversationJob?.cancel() - if (_uiState.value.isResolvingConversation || - _uiState.value.isResolvingConversationIndicatorVisible || - _uiState.value.resolvingRecipientDestination != null - ) { + val shouldClearConversationResolutionState = currentUiState.isResolvingConversation || + currentUiState.isResolvingConversationIndicatorVisible || + currentUiState.resolvingRecipientDestination != null + + if (shouldClearConversationResolutionState) { clearConversationResolutionState() } } - private fun showConversationResolutionIndicator(destination: String) { + private fun showConversationResolutionIndicator() { val currentUiState = _uiState.value - if (!currentUiState.isResolvingConversation || - currentUiState.resolvingRecipientDestination != destination || - currentUiState.isResolvingConversationIndicatorVisible - ) { - return - } + val shouldShowIndicator = currentUiState.isResolvingConversation && + !currentUiState.isResolvingConversationIndicatorVisible - updateUiState( - currentUiState.copy( - isResolvingConversationIndicatorVisible = true, - ), - ) + if (shouldShowIndicator) { + updateUiState( + currentUiState.copy( + isResolvingConversationIndicatorVisible = true, + ), + ) + } } - private fun startConversationResolution(destination: String) { + private fun startConversationResolution(resolvingRecipientDestination: String?) { updateUiState( _uiState.value.copy( isResolvingConversation = true, isResolvingConversationIndicatorVisible = false, - resolvingRecipientDestination = destination, + resolvingRecipientDestination = resolvingRecipientDestination, ), ) } + private fun resolveConversation( + destinations: List, + resolvingRecipientDestination: String?, + ) { + resolveConversationJob = viewModelScope.launch(mainDispatcher) { + startConversationResolution(resolvingRecipientDestination) + + val showIndicatorJob = launchDelayedResolutionIndicator() + + try { + resolveConversationId(destinations) + .let(::handleResolveConversationIdResult) + } finally { + showIndicatorJob.cancel() + resolveConversationJob = null + } + } + } + + private fun CoroutineScope.launchDelayedResolutionIndicator(): Job { + return launch(mainDispatcher) { + delay(timeMillis = RESOLVING_CONVERSATION_INDICATOR_DELAY_MILLIS) + showConversationResolutionIndicator() + } + } + + private fun handleResolveConversationIdResult(result: ResolveConversationIdResult) { + when (result) { + is ResolveConversationIdResult.Resolved -> { + navigateToConversation(conversationId = result.conversationId) + } + + ResolveConversationIdResult.EmptyDestinations, + ResolveConversationIdResult.NotResolved, + -> { + clearConversationResolutionState() + showMessage(messageResId = R.string.conversation_creation_failure) + } + } + } + private fun updateUiState(uiState: ConversationEntryUiState) { _uiState.value = uiState savedStateHandle[LAUNCH_GENERATION_KEY] = uiState.launchGeneration savedStateHandle[CONVERSATION_ID_KEY] = uiState.conversationId + savedStateHandle[IS_CREATING_GROUP_KEY] = uiState.isCreatingGroup savedStateHandle[PENDING_STARTUP_ATTACHMENT_TYPE_KEY] = uiState .pendingStartupAttachment ?.contentType @@ -290,27 +439,39 @@ internal class ConversationEntryViewModel @Inject constructor( savedStateHandle[PENDING_STARTUP_ATTACHMENT_URI_KEY] = uiState .pendingStartupAttachment ?.contentUri + savedStateHandle[SELECTED_GROUP_RECIPIENT_DESTINATIONS_KEY] = ArrayList( + uiState.selectedGroupRecipientDestinations, + ) } - private fun ConversationEntryLaunchRequest.toStartupAttachmentOrNull(): - ConversationEntryStartupAttachment? { + private fun buildStartupAttachmentOrNull( + contentUri: String?, + contentType: String?, + ): ConversationEntryStartupAttachment? { return when { - startupAttachmentUri != null && startupAttachmentType != null -> { + contentUri == null || contentType == null -> null + + else -> { ConversationEntryStartupAttachment( - contentType = startupAttachmentType, - contentUri = startupAttachmentUri, + contentType = contentType, + contentUri = contentUri, ) } - else -> null } } private companion object { private const val CONVERSATION_ID_KEY = "conversation_id" + private const val IS_CREATING_GROUP_KEY = "is_creating_group" private const val LAUNCH_GENERATION_KEY = "launch_generation" private const val PENDING_DRAFT_DATA_KEY = "pending_draft_data" private const val PENDING_STARTUP_ATTACHMENT_TYPE_KEY = "pending_startup_attachment_type" private const val PENDING_STARTUP_ATTACHMENT_URI_KEY = "pending_startup_attachment_uri" + + // Tracks the last launch request handled by this ViewModel even when the + // same launch generation remains in uiState for downstream side effects private const val PROCESSED_LAUNCH_GENERATION_KEY = "processed_launch_generation" + private const val SELECTED_GROUP_RECIPIENT_DESTINATIONS_KEY = + "selected_group_recipient_destinations" } } diff --git a/src/com/android/messaging/ui/conversation/v2/entry/NewChatScreen.kt b/src/com/android/messaging/ui/conversation/v2/entry/NewChatScreen.kt index ef39c00a..7fe0aea0 100644 --- a/src/com/android/messaging/ui/conversation/v2/entry/NewChatScreen.kt +++ b/src/com/android/messaging/ui/conversation/v2/entry/NewChatScreen.kt @@ -1,9 +1,36 @@ -@file:OptIn(ExperimentalMaterial3Api::class) +@file:OptIn( + ExperimentalMaterial3Api::class, +) package com.android.messaging.ui.conversation.v2.entry +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.ContentTransform +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.animation.animateColor +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.FiniteAnimationSpec +import androidx.compose.animation.core.LinearOutSlowInEasing +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.Transition +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.animation.core.updateTransition +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.animation.togetherWith import androidx.compose.foundation.background -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 @@ -13,6 +40,7 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn @@ -22,7 +50,10 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.ArrowBack +import androidx.compose.material.icons.automirrored.rounded.ArrowForward +import androidx.compose.material.icons.rounded.Check import androidx.compose.material.icons.rounded.Group +import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api @@ -39,6 +70,7 @@ import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.State import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.runtime.snapshotFlow @@ -46,10 +78,13 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.selected +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel @@ -57,11 +92,15 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import coil3.compose.AsyncImage import com.android.messaging.R import com.android.messaging.data.conversation.model.recipient.ConversationRecipient +import com.android.messaging.ui.conversation.v2.NEW_CHAT_CREATE_GROUP_NEXT_BUTTON_TEST_TAG import com.android.messaging.ui.conversation.v2.NEW_CHAT_CONTACT_RESOLVING_INDICATOR_TEST_TAG +import com.android.messaging.ui.conversation.v2.newChatContactRowTestTag import com.android.messaging.ui.conversation.v2.recipientpicker.RecipientPickerModel import com.android.messaging.ui.conversation.v2.recipientpicker.RecipientPickerViewModel import com.android.messaging.ui.conversation.v2.recipientpicker.model.RecipientPickerUiState import com.android.messaging.ui.core.AppTheme +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf private val CONTACT_CORNER_RADIUS = 18.dp private val CONTACT_MIDDLE_CORNER_RADIUS = 2.dp @@ -89,13 +128,18 @@ private const val NEW_CHAT_CONTACT_CONTENT_TYPE = "new_chat_contact" @Composable internal fun NewChatScreen( modifier: Modifier = Modifier, + isCreatingGroup: Boolean = false, isResolvingConversation: Boolean = false, isResolvingConversationIndicatorVisible: Boolean = false, onContactClick: (String) -> Unit = {}, + onContactLongClick: (String) -> Unit = {}, onCreateGroupClick: () -> Unit = {}, + onCreateGroupConfirmed: () -> Unit = {}, + onCreateGroupRecipientClick: (String) -> Unit = {}, onNavigateBack: () -> Unit = {}, pickerModel: RecipientPickerModel = hiltViewModel(), resolvingRecipientDestination: String? = null, + selectedGroupRecipientDestinations: ImmutableList = persistentListOf(), ) { val uiState by pickerModel.uiState.collectAsStateWithLifecycle() val screenContainerColor = MaterialTheme.colorScheme.surfaceVariant @@ -122,7 +166,7 @@ internal fun NewChatScreen( } }, title = { - Text(text = stringResource(id = R.string.start_new_conversation)) + Text(text = newChatTitle(isCreatingGroup = isCreatingGroup)) }, ) }, @@ -131,14 +175,19 @@ internal fun NewChatScreen( modifier = Modifier .fillMaxSize() .padding(paddingValues = contentPadding), + isCreatingGroup = isCreatingGroup, uiState = uiState, isResolvingConversation = isResolvingConversation, isResolvingConversationIndicatorVisible = isResolvingConversationIndicatorVisible, onContactClick = onContactClick, + onContactLongClick = onContactLongClick, onCreateGroupClick = onCreateGroupClick, + onCreateGroupConfirmed = onCreateGroupConfirmed, + onCreateGroupRecipientClick = onCreateGroupRecipientClick, onLoadMore = pickerModel::onLoadMore, onQueryChanged = pickerModel::onQueryChanged, resolvingRecipientDestination = resolvingRecipientDestination, + selectedGroupRecipientDestinations = selectedGroupRecipientDestinations, ) } } @@ -147,13 +196,18 @@ internal fun NewChatScreen( private fun NewChatScreenContent( uiState: RecipientPickerUiState, modifier: Modifier = Modifier, + isCreatingGroup: Boolean = false, isResolvingConversation: Boolean = false, isResolvingConversationIndicatorVisible: Boolean = false, onContactClick: (String) -> Unit, + onContactLongClick: (String) -> Unit, onCreateGroupClick: () -> Unit, + onCreateGroupConfirmed: () -> Unit, + onCreateGroupRecipientClick: (String) -> Unit, onLoadMore: () -> Unit, onQueryChanged: (String) -> Unit, resolvingRecipientDestination: String? = null, + selectedGroupRecipientDestinations: ImmutableList = persistentListOf(), ) { Surface( modifier = modifier, @@ -161,13 +215,18 @@ private fun NewChatScreenContent( ) { NewChatScreenBody( uiState = uiState, + isCreatingGroup = isCreatingGroup, isResolvingConversation = isResolvingConversation, isResolvingConversationIndicatorVisible = isResolvingConversationIndicatorVisible, onContactClick = onContactClick, + onContactLongClick = onContactLongClick, onCreateGroupClick = onCreateGroupClick, + onCreateGroupConfirmed = onCreateGroupConfirmed, + onCreateGroupRecipientClick = onCreateGroupRecipientClick, onLoadMore = onLoadMore, onQueryChanged = onQueryChanged, resolvingRecipientDestination = resolvingRecipientDestination, + selectedGroupRecipientDestinations = selectedGroupRecipientDestinations, ) } } @@ -175,13 +234,18 @@ private fun NewChatScreenContent( @Composable private fun NewChatScreenBody( uiState: RecipientPickerUiState, + isCreatingGroup: Boolean, isResolvingConversation: Boolean, isResolvingConversationIndicatorVisible: Boolean, onContactClick: (String) -> Unit, + onContactLongClick: (String) -> Unit, onCreateGroupClick: () -> Unit, + onCreateGroupConfirmed: () -> Unit, + onCreateGroupRecipientClick: (String) -> Unit, onLoadMore: () -> Unit, onQueryChanged: (String) -> Unit, resolvingRecipientDestination: String?, + selectedGroupRecipientDestinations: ImmutableList, ) { Column( modifier = Modifier @@ -201,12 +265,17 @@ private fun NewChatScreenBody( NewChatContactsContent( modifier = Modifier.fillMaxSize(), uiState = uiState, + isCreatingGroup = isCreatingGroup, contactSelectionEnabled = !isResolvingConversation, isResolvingConversationIndicatorVisible = isResolvingConversationIndicatorVisible, onContactClick = onContactClick, + onContactLongClick = onContactLongClick, onCreateGroupClick = onCreateGroupClick, + onCreateGroupConfirmed = onCreateGroupConfirmed, + onCreateGroupRecipientClick = onCreateGroupRecipientClick, onLoadMore = onLoadMore, resolvingRecipientDestination = resolvingRecipientDestination, + selectedGroupRecipientDestinations = selectedGroupRecipientDestinations, ) } } @@ -260,20 +329,46 @@ private fun NewChatQueryField( ) } +@Composable +private fun newChatTitle( + isCreatingGroup: Boolean, +): String { + return when { + isCreatingGroup -> stringResource(id = R.string.conversation_new_group) + else -> stringResource(id = R.string.start_new_conversation) + } +} + @Composable private fun NewChatContactsContent( modifier: Modifier = Modifier, uiState: RecipientPickerUiState, + isCreatingGroup: Boolean, contactSelectionEnabled: Boolean, isResolvingConversationIndicatorVisible: Boolean, onContactClick: (String) -> Unit, + onContactLongClick: (String) -> Unit, onCreateGroupClick: () -> Unit, + onCreateGroupConfirmed: () -> Unit, + onCreateGroupRecipientClick: (String) -> Unit, onLoadMore: () -> Unit, resolvingRecipientDestination: String?, + selectedGroupRecipientDestinations: ImmutableList, ) { val contacts = uiState.contacts val lastContactIndex = contacts.lastIndex val listState = rememberLazyListState() + val showCreateGroupNextButton = isCreatingGroup && + selectedGroupRecipientDestinations.isNotEmpty() + + val animatedListBottomPadding by animateDpAsState( + targetValue = when { + showCreateGroupNextButton -> 100.dp + else -> 16.dp + }, + animationSpec = defaultSpatialAnimationSpec(), + label = "newChatListBottomPadding", + ) LaunchedEffect( listState, @@ -293,72 +388,107 @@ private fun NewChatContactsContent( } } - LazyColumn( - modifier = modifier, - state = listState, - contentPadding = PaddingValues(bottom = 16.dp), - ) { - item { - NewGroupButton( - modifier = Modifier - .fillMaxWidth(), - enabled = true, - onClick = onCreateGroupClick, - ) - } - - item { - Spacer(modifier = Modifier.height(height = 12.dp)) - } - - when { - uiState.isLoading -> { - item { - NewChatLoadingState() + Box(modifier = modifier) { + LazyColumn( + modifier = Modifier.fillMaxSize(), + state = listState, + contentPadding = PaddingValues( + bottom = animatedListBottomPadding, + ), + ) { + item { + AnimatedVisibility( + visible = !isCreatingGroup, + enter = newGroupButtonEnterTransition(), + exit = newGroupButtonExitTransition(), + ) { + Column( + verticalArrangement = Arrangement.spacedBy(space = 12.dp), + ) { + NewGroupButton( + modifier = Modifier + .fillMaxWidth(), + enabled = true, + onClick = onCreateGroupClick, + ) + Spacer(modifier = Modifier.height(height = 12.dp)) + } } } - uiState.contacts.isEmpty() || !uiState.hasContactsPermission -> { - item { - NewChatEmptyState() + when { + uiState.isLoading -> { + item { + NewChatLoadingState() + } } - } - else -> { - itemsIndexed( - items = contacts, - key = { _, contact -> contact.id }, - contentType = { _, _ -> - NEW_CHAT_CONTACT_CONTENT_TYPE - }, - ) { index, contact -> - val bottomPadding = when { - index == lastContactIndex -> 0.dp - else -> 2.dp + uiState.contacts.isEmpty() || !uiState.hasContactsPermission -> { + item { + NewChatEmptyState() } + } - NewChatContactRow( - modifier = Modifier - .padding(bottom = bottomPadding), - contact = contact, - shape = newChatContactRowShape( - index = index, - totalCount = contacts.size, - ), - enabled = contactSelectionEnabled, - onContactClick = onContactClick, - showResolvingIndicator = isResolvingConversationIndicatorVisible && - resolvingRecipientDestination == contact.destination, - ) + else -> { + itemsIndexed( + items = contacts, + key = { _, contact -> contact.id }, + contentType = { _, _ -> + NEW_CHAT_CONTACT_CONTENT_TYPE + }, + ) { index, contact -> + val bottomPadding = when { + index == lastContactIndex -> 0.dp + else -> 2.dp + } + + NewChatContactRow( + modifier = Modifier + .padding(bottom = bottomPadding), + contact = contact, + enabled = contactSelectionEnabled, + isCreateGroupMode = isCreatingGroup, + isSelected = selectedGroupRecipientDestinations.contains( + contact.destination, + ), + onContactClick = onContactClick, + onContactLongClick = onContactLongClick, + onCreateGroupRecipientClick = onCreateGroupRecipientClick, + shape = newChatContactRowShape( + index = index, + totalCount = contacts.size, + ), + showResolvingIndicator = !isCreatingGroup && + isResolvingConversationIndicatorVisible && + resolvingRecipientDestination == contact.destination, + ) + } } } - } - if (uiState.isLoadingMore) { - item { - NewChatLoadingMoreState() + if (uiState.isLoadingMore) { + item { + NewChatLoadingMoreState() + } } } + + AnimatedVisibility( + modifier = Modifier + .align(alignment = Alignment.BottomEnd), + visible = showCreateGroupNextButton, + enter = createGroupNextButtonEnterTransition(), + exit = createGroupNextButtonExitTransition(), + ) { + CreateGroupNextButton( + modifier = Modifier + .navigationBarsPadding() + .padding(end = 8.dp, bottom = 8.dp), + enabled = !uiState.isLoading && contactSelectionEnabled, + isLoading = isResolvingConversationIndicatorVisible, + onClick = onCreateGroupConfirmed, + ) + } } } @@ -435,36 +565,120 @@ private fun NewGroupButton( } } +@Composable +private fun CreateGroupNextButton( + modifier: Modifier = Modifier, + enabled: Boolean, + isLoading: Boolean, + onClick: () -> Unit, +) { + Button( + modifier = modifier + .animateContentSize( + animationSpec = defaultSpatialAnimationSpec(), + ) + .testTag(NEW_CHAT_CREATE_GROUP_NEXT_BUTTON_TEST_TAG), + onClick = onClick, + enabled = enabled, + shape = RoundedCornerShape(size = 18.dp), + ) { + AnimatedContent( + targetState = isLoading, + transitionSpec = { + nextButtonContentTransform() + }, + label = "createGroupNextButtonContent", + ) { isButtonLoading -> + if (isButtonLoading) { + CircularProgressIndicator( + modifier = Modifier.size(size = 18.dp), + color = MaterialTheme.colorScheme.onPrimary, + strokeWidth = 2.dp, + ) + } else { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + Text(text = stringResource(id = R.string.next)) + Spacer(modifier = Modifier.size(size = 8.dp)) + Icon( + imageVector = Icons.AutoMirrored.Rounded.ArrowForward, + contentDescription = null, + ) + } + } + } + } +} + @Composable private fun NewChatContactRow( modifier: Modifier = Modifier, contact: ConversationRecipient, shape: RoundedCornerShape, enabled: Boolean, + isCreateGroupMode: Boolean, + isSelected: Boolean, onContactClick: (String) -> Unit, + onContactLongClick: (String) -> Unit, + onCreateGroupRecipientClick: (String) -> Unit, showResolvingIndicator: Boolean, ) { val hapticFeedback = LocalHapticFeedback.current + val selectionTransition = updateTransition( + targetState = isSelected, + label = "newChatContactSelection", + ) + val containerColor by selectionTransition.animateContainerColor() + val primaryTextColor by selectionTransition.animatePrimaryTextColor() + val secondaryTextColor by selectionTransition.animateSecondaryTextColor() Row( modifier = Modifier .then(other = modifier) .fillMaxWidth() + .testTag(newChatContactRowTestTag(contactId = contact.id)) + .semantics { + selected = isSelected + } .background( - color = MaterialTheme.colorScheme.background, + color = containerColor, shape = shape, ) - .clickable( + .combinedClickable( enabled = enabled, onClick = { hapticFeedback.performHapticFeedback(HapticFeedbackType.ContextClick) - onContactClick(contact.destination) + when { + isCreateGroupMode -> { + onCreateGroupRecipientClick(contact.destination) + } + + else -> { + onContactClick(contact.destination) + } + } + }, + onLongClick = { + hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) + when { + isCreateGroupMode -> { + onCreateGroupRecipientClick(contact.destination) + } + + else -> { + onContactLongClick(contact.destination) + } + } }, ) .padding(horizontal = 16.dp, vertical = 14.dp), verticalAlignment = Alignment.CenterVertically, ) { - NewChatContactAvatar(contact = contact) + NewChatContactAvatar( + contact = contact, + isSelected = isSelected, + ) Column( modifier = Modifier @@ -477,6 +691,7 @@ private fun NewChatContactRow( maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.bodyLarge, + color = primaryTextColor, ) contact.secondaryText?.let { secondaryText -> @@ -485,12 +700,16 @@ private fun NewChatContactRow( maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, + color = secondaryTextColor, ) } } - if (showResolvingIndicator) { + AnimatedVisibility( + visible = showResolvingIndicator, + enter = resolvingIndicatorEnterTransition(), + exit = resolvingIndicatorExitTransition(), + ) { CircularProgressIndicator( modifier = Modifier .size(size = 20.dp) @@ -516,25 +735,71 @@ private fun newChatContactRowShape( @Composable private fun NewChatContactAvatar( contact: ConversationRecipient, + isSelected: Boolean, ) { - return when { - contact.photoUri == null -> { - NewChatContactTextAvatar( - contact = contact, - ) - } - else -> { - AsyncImage( - model = contact.photoUri, - contentDescription = contact.displayName, - modifier = Modifier - .size(size = 40.dp) - .clip(shape = CircleShape), - ) + val avatarScale by rememberContactAvatarScale( + isSelected = isSelected, + ) + + AnimatedContent( + targetState = isSelected, + transitionSpec = { + contactAvatarContentTransform() + }, + label = "newChatContactAvatar", + ) { isSelectedState -> + Box( + modifier = Modifier.graphicsLayer { + scaleX = avatarScale + scaleY = avatarScale + }, + ) { + when { + isSelectedState -> { + SelectedContactAvatar() + } + + contact.photoUri == null -> { + NewChatContactTextAvatar( + contact = contact, + ) + } + + else -> { + AsyncImage( + model = contact.photoUri, + contentDescription = contact.displayName, + modifier = Modifier + .size(size = 40.dp) + .clip(shape = CircleShape), + ) + } + } } } } +@Composable +private fun SelectedContactAvatar( + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier + .size(size = 40.dp) + .background( + color = MaterialTheme.colorScheme.primary, + shape = CircleShape, + ), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Rounded.Check, + contentDescription = null, + tint = MaterialTheme.colorScheme.onPrimary, + ) + } +} + @Composable private fun NewChatContactTextAvatar( modifier: Modifier = Modifier, @@ -568,24 +833,233 @@ private fun contactAvatarLabel(contact: ConversationRecipient): String { return firstCharacter.uppercaseChar().toString() } +private fun newGroupButtonEnterTransition(): EnterTransition { + return fadeIn( + animationSpec = defaultEffectsAnimationSpec(), + ) + slideInVertically( + animationSpec = defaultSpatialAnimationSpec(), + initialOffsetY = { fullHeight -> + -fullHeight / 4 + }, + ) +} + +private fun newGroupButtonExitTransition(): ExitTransition { + return fadeOut( + animationSpec = fastEffectsAnimationSpec(), + ) + shrinkVertically( + animationSpec = defaultSpatialAnimationSpec(), + shrinkTowards = Alignment.Top, + ) +} + +private fun createGroupNextButtonEnterTransition(): EnterTransition { + return fadeIn( + animationSpec = defaultEffectsAnimationSpec(), + ) + slideInVertically( + animationSpec = defaultSpatialAnimationSpec(), + initialOffsetY = { fullHeight -> + fullHeight / 2 + }, + ) + scaleIn( + animationSpec = defaultSpatialAnimationSpec(), + initialScale = 0.9f, + ) +} + +private fun createGroupNextButtonExitTransition(): ExitTransition { + return fadeOut( + animationSpec = fastEffectsAnimationSpec(), + ) + slideOutVertically( + animationSpec = defaultSpatialAnimationSpec(), + targetOffsetY = { fullHeight -> + fullHeight / 2 + }, + ) + scaleOut( + animationSpec = defaultSpatialAnimationSpec(), + targetScale = 0.9f, + ) +} + +private fun nextButtonContentTransform(): ContentTransform { + return (fadeIn( + animationSpec = defaultEffectsAnimationSpec(), + ) + scaleIn( + animationSpec = defaultSpatialAnimationSpec(), + initialScale = 0.9f, + )).togetherWith( + fadeOut( + animationSpec = fastEffectsAnimationSpec(), + ) + scaleOut( + animationSpec = defaultSpatialAnimationSpec(), + targetScale = 0.9f, + ), + ) +} + +private fun resolvingIndicatorEnterTransition(): EnterTransition { + return fadeIn( + animationSpec = defaultEffectsAnimationSpec(), + ) + scaleIn( + animationSpec = defaultSpatialAnimationSpec(), + initialScale = 0.8f, + ) +} + +private fun resolvingIndicatorExitTransition(): ExitTransition { + return fadeOut( + animationSpec = fastEffectsAnimationSpec(), + ) + scaleOut( + animationSpec = defaultSpatialAnimationSpec(), + targetScale = 0.8f, + ) +} + +private fun contactAvatarContentTransform(): ContentTransform { + return (fadeIn( + animationSpec = defaultEffectsAnimationSpec(), + ) + scaleIn( + animationSpec = defaultSpatialAnimationSpec(), + initialScale = 0.8f, + )).togetherWith( + fadeOut( + animationSpec = fastEffectsAnimationSpec(), + ) + scaleOut( + animationSpec = defaultSpatialAnimationSpec(), + targetScale = 0.8f, + ), + ) +} + +@Composable +private fun rememberContactAvatarScale( + isSelected: Boolean, +): State { + val selectionTransition = updateTransition( + targetState = isSelected, + label = "newChatContactAvatarScale", + ) + + return selectionTransition.animateFloat( + transitionSpec = { + defaultSpatialAnimationSpec() + }, + label = "newChatContactAvatarScaleValue", + targetValueByState = { isAvatarSelected -> + when { + isAvatarSelected -> 1f + else -> 0.9f + } + }, + ) +} + +@Composable +private fun Transition.animateContainerColor(): State { + return animateColor( + transitionSpec = { + contactSelectionAnimationSpec() + }, + label = "newChatContactContainerColor", + targetValueByState = { isContactSelected -> + when { + isContactSelected -> MaterialTheme.colorScheme.secondaryContainer + else -> MaterialTheme.colorScheme.background + } + }, + ) +} + +@Composable +private fun Transition.animatePrimaryTextColor(): State { + return animateColor( + transitionSpec = { + contactSelectionAnimationSpec() + }, + label = "newChatContactPrimaryTextColor", + targetValueByState = { isContactSelected -> + when { + isContactSelected -> MaterialTheme.colorScheme.onSecondaryContainer + else -> MaterialTheme.colorScheme.onSurface + } + }, + ) +} + +@Composable +private fun Transition.animateSecondaryTextColor(): State { + return animateColor( + transitionSpec = { + contactSelectionAnimationSpec() + }, + label = "newChatContactSecondaryTextColor", + targetValueByState = { isContactSelected -> + when { + isContactSelected -> { + MaterialTheme.colorScheme.onSecondaryContainer.copy(alpha = 0.7f) + } + + else -> { + MaterialTheme.colorScheme.onSurfaceVariant + } + } + }, + ) +} + +private fun contactSelectionAnimationSpec(): FiniteAnimationSpec { + return tween( + durationMillis = 200, + easing = FastOutSlowInEasing, + ) +} + +private fun defaultEffectsAnimationSpec(): FiniteAnimationSpec { + return tween( + durationMillis = 200, + easing = LinearOutSlowInEasing, + ) +} + +private fun fastEffectsAnimationSpec(): FiniteAnimationSpec { + return tween( + durationMillis = 150, + easing = FastOutSlowInEasing, + ) +} + +private fun defaultSpatialAnimationSpec(): FiniteAnimationSpec { + return spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = Spring.StiffnessMediumLow, + ) +} + @Composable private fun NewChatScreenPreviewContent( uiState: RecipientPickerUiState, + isCreatingGroup: Boolean = false, isResolvingConversation: Boolean = false, isResolvingConversationIndicatorVisible: Boolean = false, resolvingRecipientDestination: String? = null, + selectedGroupRecipientDestinations: ImmutableList = persistentListOf(), ) { AppTheme { NewChatScreenContent( modifier = Modifier.fillMaxSize(), uiState = uiState, + isCreatingGroup = isCreatingGroup, isResolvingConversation = isResolvingConversation, isResolvingConversationIndicatorVisible = isResolvingConversationIndicatorVisible, onContactClick = {}, + onContactLongClick = {}, onCreateGroupClick = {}, + onCreateGroupConfirmed = {}, + onCreateGroupRecipientClick = {}, onLoadMore = {}, onQueryChanged = {}, resolvingRecipientDestination = resolvingRecipientDestination, + selectedGroupRecipientDestinations = selectedGroupRecipientDestinations, ) } } diff --git a/src/com/android/messaging/ui/conversation/v2/entry/model/ConversationEntryUiState.kt b/src/com/android/messaging/ui/conversation/v2/entry/model/ConversationEntryUiState.kt index 5759e8ab..e1598a5f 100644 --- a/src/com/android/messaging/ui/conversation/v2/entry/model/ConversationEntryUiState.kt +++ b/src/com/android/messaging/ui/conversation/v2/entry/model/ConversationEntryUiState.kt @@ -2,16 +2,20 @@ package com.android.messaging.ui.conversation.v2.entry.model import androidx.compose.runtime.Immutable import com.android.messaging.data.conversation.model.draft.ConversationDraft +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf @Immutable internal data class ConversationEntryUiState( val launchGeneration: Int? = null, val conversationId: String? = null, + val isCreatingGroup: Boolean = false, val isResolvingConversation: Boolean = false, val isResolvingConversationIndicatorVisible: Boolean = false, val pendingDraft: ConversationDraft? = null, val pendingStartupAttachment: ConversationEntryStartupAttachment? = null, val resolvingRecipientDestination: String? = null, + val selectedGroupRecipientDestinations: ImmutableList = persistentListOf(), ) @Immutable diff --git a/src/com/android/messaging/ui/conversation/v2/navigation/ConversationNavGraph.kt b/src/com/android/messaging/ui/conversation/v2/navigation/ConversationNavGraph.kt index e9a21629..89041fec 100644 --- a/src/com/android/messaging/ui/conversation/v2/navigation/ConversationNavGraph.kt +++ b/src/com/android/messaging/ui/conversation/v2/navigation/ConversationNavGraph.kt @@ -88,14 +88,27 @@ internal fun ConversationNavGraph( val currentEntryModel = latestEntryModel.value NewChatScreen( + isCreatingGroup = currentEntryUiState.isCreatingGroup, isResolvingConversation = currentEntryUiState.isResolvingConversation, isResolvingConversationIndicatorVisible = currentEntryUiState .isResolvingConversationIndicatorVisible, onContactClick = currentEntryModel::onNewChatRecipientSelected, + onContactLongClick = currentEntryModel::onNewChatRecipientLongPressed, onCreateGroupClick = currentEntryModel::onCreateGroupRequested, - onNavigateBack = currentEntryModel::navigateBack, + onCreateGroupConfirmed = currentEntryModel::onCreateGroupConfirmed, + onCreateGroupRecipientClick = currentEntryModel::onCreateGroupRecipientClicked, + onNavigateBack = { + handleNewChatBack( + entryModel = currentEntryModel, + entryUiState = currentEntryUiState, + backStack = backStack, + onFinish = latestOnFinish.value, + ) + }, resolvingRecipientDestination = currentEntryUiState .resolvingRecipientDestination, + selectedGroupRecipientDestinations = currentEntryUiState + .selectedGroupRecipientDestinations, ) } @@ -127,9 +140,11 @@ internal fun ConversationNavGraph( backStack = backStack, modifier = modifier, onBack = { - popBackStackOrFinish( + handleNavBack( backStack = backStack, - onFinish = onFinish, + entryModel = latestEntryModel.value, + entryUiState = latestEntryUiState.value, + onFinish = latestOnFinish.value, ) }, entryDecorators = entryDecorators, @@ -182,6 +197,40 @@ private fun popBackStackOrFinish( onFinish() } +private fun handleNavBack( + backStack: MutableList, + entryModel: ConversationEntryModel, + entryUiState: ConversationEntryUiState, + onFinish: () -> Unit, +) { + if (backStack.lastOrNull() == NewChatNavKey && entryUiState.isCreatingGroup) { + entryModel.onCreateGroupCanceled() + return + } + + popBackStackOrFinish( + backStack = backStack, + onFinish = onFinish, + ) +} + +private fun handleNewChatBack( + entryModel: ConversationEntryModel, + entryUiState: ConversationEntryUiState, + backStack: MutableList, + onFinish: () -> Unit, +) { + if (entryUiState.isCreatingGroup) { + entryModel.onCreateGroupCanceled() + return + } + + popBackStackOrFinish( + backStack = backStack, + onFinish = onFinish, + ) +} + private fun pendingStartupAttachmentForConversation( entryUiState: ConversationEntryUiState, conversationId: String, From 0f32146073298f6d3adefe88561394fd530d1743 Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Fri, 17 Apr 2026 16:58:27 +0300 Subject: [PATCH 36/99] Extract shared recipient selection UI and delegate --- .../ConversationViewModelBindsModule.kt | 8 + .../ui/conversation/v2/entry/NewChatScreen.kt | 947 ++---------------- .../RecipientPickerViewModel.kt | 323 +----- .../RecipientSelectionContent.kt | 843 ++++++++++++++++ .../RecipientSelectionContentUiState.kt | 35 + .../delegate/RecipientPickerDelegate.kt | 408 ++++++++ 6 files changed, 1404 insertions(+), 1160 deletions(-) create mode 100644 src/com/android/messaging/ui/conversation/v2/recipientpicker/RecipientSelectionContent.kt create mode 100644 src/com/android/messaging/ui/conversation/v2/recipientpicker/RecipientSelectionContentUiState.kt create mode 100644 src/com/android/messaging/ui/conversation/v2/recipientpicker/delegate/RecipientPickerDelegate.kt diff --git a/src/com/android/messaging/di/conversation/ConversationViewModelBindsModule.kt b/src/com/android/messaging/di/conversation/ConversationViewModelBindsModule.kt index 1e362bbb..171b4fea 100644 --- a/src/com/android/messaging/di/conversation/ConversationViewModelBindsModule.kt +++ b/src/com/android/messaging/di/conversation/ConversationViewModelBindsModule.kt @@ -8,6 +8,8 @@ import com.android.messaging.ui.conversation.v2.messages.delegate.ConversationMe import com.android.messaging.ui.conversation.v2.messages.delegate.ConversationMessagesDelegateImpl import com.android.messaging.ui.conversation.v2.metadata.delegate.ConversationMetadataDelegate import com.android.messaging.ui.conversation.v2.metadata.delegate.ConversationMetadataDelegateImpl +import com.android.messaging.ui.conversation.v2.recipientpicker.delegate.RecipientPickerDelegate +import com.android.messaging.ui.conversation.v2.recipientpicker.delegate.RecipientPickerDelegateImpl import dagger.Binds import dagger.Module import dagger.hilt.InstallIn @@ -41,4 +43,10 @@ internal abstract class ConversationViewModelBindsModule { abstract fun bindConversationMetadataDelegate( impl: ConversationMetadataDelegateImpl, ): ConversationMetadataDelegate + + @Binds + @ViewModelScoped + abstract fun bindRecipientPickerDelegate( + impl: RecipientPickerDelegateImpl, + ): RecipientPickerDelegate } diff --git a/src/com/android/messaging/ui/conversation/v2/entry/NewChatScreen.kt b/src/com/android/messaging/ui/conversation/v2/entry/NewChatScreen.kt index 7fe0aea0..bade6d54 100644 --- a/src/com/android/messaging/ui/conversation/v2/entry/NewChatScreen.kt +++ b/src/com/android/messaging/ui/conversation/v2/entry/NewChatScreen.kt @@ -4,126 +4,65 @@ package com.android.messaging.ui.conversation.v2.entry -import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.ContentTransform import androidx.compose.animation.EnterTransition import androidx.compose.animation.ExitTransition -import androidx.compose.animation.animateColor -import androidx.compose.animation.animateContentSize import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.FiniteAnimationSpec import androidx.compose.animation.core.LinearOutSlowInEasing import androidx.compose.animation.core.Spring -import androidx.compose.animation.core.Transition -import androidx.compose.animation.core.animateDpAsState -import androidx.compose.animation.core.animateFloat import androidx.compose.animation.core.spring import androidx.compose.animation.core.tween -import androidx.compose.animation.core.updateTransition import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut -import androidx.compose.animation.scaleIn -import androidx.compose.animation.scaleOut import androidx.compose.animation.shrinkVertically import androidx.compose.animation.slideInVertically -import androidx.compose.animation.slideOutVertically -import androidx.compose.animation.togetherWith -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 -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.ArrowBack -import androidx.compose.material.icons.automirrored.rounded.ArrowForward -import androidx.compose.material.icons.rounded.Check import androidx.compose.material.icons.rounded.Group -import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold -import androidx.compose.material3.Surface import androidx.compose.material3.Text -import androidx.compose.material3.TextField -import androidx.compose.material3.TextFieldDefaults import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.State import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import androidx.compose.runtime.snapshotFlow -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.platform.LocalHapticFeedback -import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource -import androidx.compose.ui.semantics.selected -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle -import coil3.compose.AsyncImage import com.android.messaging.R -import com.android.messaging.data.conversation.model.recipient.ConversationRecipient -import com.android.messaging.ui.conversation.v2.NEW_CHAT_CREATE_GROUP_NEXT_BUTTON_TEST_TAG import com.android.messaging.ui.conversation.v2.NEW_CHAT_CONTACT_RESOLVING_INDICATOR_TEST_TAG +import com.android.messaging.ui.conversation.v2.NEW_CHAT_CREATE_GROUP_NEXT_BUTTON_TEST_TAG import com.android.messaging.ui.conversation.v2.newChatContactRowTestTag import com.android.messaging.ui.conversation.v2.recipientpicker.RecipientPickerModel import com.android.messaging.ui.conversation.v2.recipientpicker.RecipientPickerViewModel +import com.android.messaging.ui.conversation.v2.recipientpicker.RecipientSelectionContent +import com.android.messaging.ui.conversation.v2.recipientpicker.RecipientSelectionContentUiState +import com.android.messaging.ui.conversation.v2.recipientpicker.RecipientSelectionPrimaryActionUiState +import com.android.messaging.ui.conversation.v2.recipientpicker.RecipientSelectionRowDecorators +import com.android.messaging.ui.conversation.v2.recipientpicker.RecipientSelectionStrings import com.android.messaging.ui.conversation.v2.recipientpicker.model.RecipientPickerUiState -import com.android.messaging.ui.core.AppTheme import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf - -private val CONTACT_CORNER_RADIUS = 18.dp -private val CONTACT_MIDDLE_CORNER_RADIUS = 2.dp - -private val SearchFieldShape = RoundedCornerShape(size = 22.dp) - -private val TopContactShape = RoundedCornerShape( - topStart = CONTACT_CORNER_RADIUS, - topEnd = CONTACT_CORNER_RADIUS, - bottomStart = CONTACT_MIDDLE_CORNER_RADIUS, - bottomEnd = CONTACT_MIDDLE_CORNER_RADIUS, -) -private val BottomContactShape = RoundedCornerShape( - topStart = CONTACT_MIDDLE_CORNER_RADIUS, - topEnd = CONTACT_MIDDLE_CORNER_RADIUS, - bottomStart = CONTACT_CORNER_RADIUS, - bottomEnd = CONTACT_CORNER_RADIUS, -) -private val MiddleContactShape = RoundedCornerShape(size = CONTACT_MIDDLE_CORNER_RADIUS) -private val SingleContactShape = RoundedCornerShape(size = CONTACT_CORNER_RADIUS) - -private const val CONTACTS_LOAD_MORE_THRESHOLD = 10 -private const val NEW_CHAT_CONTACT_CONTENT_TYPE = "new_chat_contact" +import kotlinx.collections.immutable.persistentSetOf +import kotlinx.collections.immutable.toImmutableSet @Composable internal fun NewChatScreen( @@ -171,368 +110,141 @@ internal fun NewChatScreen( ) }, ) { contentPadding -> - NewChatScreenContent( + NewChatRecipientSelectionContent( modifier = Modifier .fillMaxSize() .padding(paddingValues = contentPadding), + pickerUiState = uiState, isCreatingGroup = isCreatingGroup, - uiState = uiState, isResolvingConversation = isResolvingConversation, isResolvingConversationIndicatorVisible = isResolvingConversationIndicatorVisible, - onContactClick = onContactClick, - onContactLongClick = onContactLongClick, - onCreateGroupClick = onCreateGroupClick, - onCreateGroupConfirmed = onCreateGroupConfirmed, - onCreateGroupRecipientClick = onCreateGroupRecipientClick, - onLoadMore = pickerModel::onLoadMore, - onQueryChanged = pickerModel::onQueryChanged, resolvingRecipientDestination = resolvingRecipientDestination, selectedGroupRecipientDestinations = selectedGroupRecipientDestinations, - ) - } -} - -@Composable -private fun NewChatScreenContent( - uiState: RecipientPickerUiState, - modifier: Modifier = Modifier, - isCreatingGroup: Boolean = false, - isResolvingConversation: Boolean = false, - isResolvingConversationIndicatorVisible: Boolean = false, - onContactClick: (String) -> Unit, - onContactLongClick: (String) -> Unit, - onCreateGroupClick: () -> Unit, - onCreateGroupConfirmed: () -> Unit, - onCreateGroupRecipientClick: (String) -> Unit, - onLoadMore: () -> Unit, - onQueryChanged: (String) -> Unit, - resolvingRecipientDestination: String? = null, - selectedGroupRecipientDestinations: ImmutableList = persistentListOf(), -) { - Surface( - modifier = modifier, - color = MaterialTheme.colorScheme.surfaceVariant, - ) { - NewChatScreenBody( - uiState = uiState, - isCreatingGroup = isCreatingGroup, - isResolvingConversation = isResolvingConversation, - isResolvingConversationIndicatorVisible = isResolvingConversationIndicatorVisible, + onLoadMore = pickerModel::onLoadMore, + onQueryChanged = pickerModel::onQueryChanged, onContactClick = onContactClick, onContactLongClick = onContactLongClick, onCreateGroupClick = onCreateGroupClick, onCreateGroupConfirmed = onCreateGroupConfirmed, onCreateGroupRecipientClick = onCreateGroupRecipientClick, - onLoadMore = onLoadMore, - onQueryChanged = onQueryChanged, - resolvingRecipientDestination = resolvingRecipientDestination, - selectedGroupRecipientDestinations = selectedGroupRecipientDestinations, ) } } @Composable -private fun NewChatScreenBody( - uiState: RecipientPickerUiState, +private fun NewChatRecipientSelectionContent( + pickerUiState: RecipientPickerUiState, isCreatingGroup: Boolean, isResolvingConversation: Boolean, isResolvingConversationIndicatorVisible: Boolean, - onContactClick: (String) -> Unit, - onContactLongClick: (String) -> Unit, - onCreateGroupClick: () -> Unit, - onCreateGroupConfirmed: () -> Unit, - onCreateGroupRecipientClick: (String) -> Unit, - onLoadMore: () -> Unit, - onQueryChanged: (String) -> Unit, resolvingRecipientDestination: String?, selectedGroupRecipientDestinations: ImmutableList, -) { - Column( - modifier = Modifier - .fillMaxSize() - .padding(horizontal = 16.dp), - ) { - Spacer(modifier = Modifier.height(height = 16.dp)) - - NewChatQueryField( - query = uiState.query, - enabled = !isResolvingConversation, - onQueryChanged = onQueryChanged, - ) - - Spacer(modifier = Modifier.height(height = 12.dp)) - - NewChatContactsContent( - modifier = Modifier.fillMaxSize(), - uiState = uiState, - isCreatingGroup = isCreatingGroup, - contactSelectionEnabled = !isResolvingConversation, - isResolvingConversationIndicatorVisible = isResolvingConversationIndicatorVisible, - onContactClick = onContactClick, - onContactLongClick = onContactLongClick, - onCreateGroupClick = onCreateGroupClick, - onCreateGroupConfirmed = onCreateGroupConfirmed, - onCreateGroupRecipientClick = onCreateGroupRecipientClick, - onLoadMore = onLoadMore, - resolvingRecipientDestination = resolvingRecipientDestination, - selectedGroupRecipientDestinations = selectedGroupRecipientDestinations, - ) - } -} - -@Composable -private fun NewChatQueryField( - query: String, - enabled: Boolean, + onLoadMore: () -> Unit, onQueryChanged: (String) -> Unit, -) { - val colorScheme = MaterialTheme.colorScheme - - TextField( - modifier = Modifier - .fillMaxWidth(), - value = query, - onValueChange = onQueryChanged, - enabled = enabled, - singleLine = true, - shape = SearchFieldShape, - colors = TextFieldDefaults.colors( - focusedContainerColor = colorScheme.surface, - unfocusedContainerColor = colorScheme.surface, - disabledContainerColor = colorScheme.surface, - focusedIndicatorColor = Color.Transparent, - unfocusedIndicatorColor = Color.Transparent, - disabledIndicatorColor = Color.Transparent, - focusedTextColor = colorScheme.onSurface, - unfocusedTextColor = colorScheme.onSurface, - disabledTextColor = colorScheme.onSurface, - focusedPlaceholderColor = colorScheme.onSurfaceVariant, - unfocusedPlaceholderColor = colorScheme.onSurfaceVariant, - disabledPlaceholderColor = colorScheme.onSurfaceVariant, - focusedPrefixColor = colorScheme.onSurfaceVariant, - unfocusedPrefixColor = colorScheme.onSurfaceVariant, - disabledPrefixColor = colorScheme.onSurfaceVariant, - ), - prefix = { - Text( - modifier = Modifier - .padding(end = 12.dp), - text = stringResource(id = R.string.new_chat_recipient_prefix), - style = MaterialTheme.typography.bodyLarge, - ) - }, - placeholder = { - Text( - text = stringResource(id = R.string.new_chat_query_hint), - ) - }, - ) -} - -@Composable -private fun newChatTitle( - isCreatingGroup: Boolean, -): String { - return when { - isCreatingGroup -> stringResource(id = R.string.conversation_new_group) - else -> stringResource(id = R.string.start_new_conversation) - } -} - -@Composable -private fun NewChatContactsContent( - modifier: Modifier = Modifier, - uiState: RecipientPickerUiState, - isCreatingGroup: Boolean, - contactSelectionEnabled: Boolean, - isResolvingConversationIndicatorVisible: Boolean, onContactClick: (String) -> Unit, onContactLongClick: (String) -> Unit, onCreateGroupClick: () -> Unit, onCreateGroupConfirmed: () -> Unit, onCreateGroupRecipientClick: (String) -> Unit, - onLoadMore: () -> Unit, - resolvingRecipientDestination: String?, - selectedGroupRecipientDestinations: ImmutableList, + modifier: Modifier = Modifier, ) { - val contacts = uiState.contacts - val lastContactIndex = contacts.lastIndex - val listState = rememberLazyListState() - val showCreateGroupNextButton = isCreatingGroup && - selectedGroupRecipientDestinations.isNotEmpty() - - val animatedListBottomPadding by animateDpAsState( - targetValue = when { - showCreateGroupNextButton -> 100.dp - else -> 16.dp - }, - animationSpec = defaultSpatialAnimationSpec(), - label = "newChatListBottomPadding", - ) - - LaunchedEffect( - listState, - uiState.canLoadMore, - uiState.isLoading, - uiState.isLoadingMore, - contacts.size, - ) { - snapshotFlow { - val lastVisibleIndex = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1 - lastVisibleIndex >= lastContactIndex - CONTACTS_LOAD_MORE_THRESHOLD - }.collect { shouldLoadMore -> - val isLoading = uiState.isLoading || uiState.isLoadingMore - if (shouldLoadMore && uiState.canLoadMore && !isLoading) { - onLoadMore() - } + val primaryAction = when { + isCreatingGroup && selectedGroupRecipientDestinations.isNotEmpty() -> { + RecipientSelectionPrimaryActionUiState( + text = stringResource(id = R.string.next), + isEnabled = !pickerUiState.isLoading && !isResolvingConversation, + isLoading = isResolvingConversationIndicatorVisible, + testTag = NEW_CHAT_CREATE_GROUP_NEXT_BUTTON_TEST_TAG, + ) } - } - Box(modifier = modifier) { - LazyColumn( - modifier = Modifier.fillMaxSize(), - state = listState, - contentPadding = PaddingValues( - bottom = animatedListBottomPadding, - ), - ) { - item { - AnimatedVisibility( - visible = !isCreatingGroup, - enter = newGroupButtonEnterTransition(), - exit = newGroupButtonExitTransition(), - ) { - Column( - verticalArrangement = Arrangement.spacedBy(space = 12.dp), - ) { - NewGroupButton( - modifier = Modifier - .fillMaxWidth(), - enabled = true, - onClick = onCreateGroupClick, - ) - Spacer(modifier = Modifier.height(height = 12.dp)) - } - } - } + else -> null + } + RecipientSelectionContent( + uiState = RecipientSelectionContentUiState( + picker = pickerUiState, + primaryAction = primaryAction, + selectedRecipientDestinations = when { + isCreatingGroup -> selectedGroupRecipientDestinations.toImmutableSet() + else -> persistentSetOf() + }, + isQueryEnabled = !isResolvingConversation, + ), + strings = RecipientSelectionStrings( + queryPrefixText = stringResource(id = R.string.new_chat_recipient_prefix), + queryPlaceholderText = stringResource(id = R.string.new_chat_query_hint), + ), + rowDecorators = RecipientSelectionRowDecorators( + recipientRowTestTag = { contact -> + newChatContactRowTestTag(contactId = contact.id) + }, + showRecipientTrailingIndicator = { contact -> + !isCreatingGroup && + isResolvingConversationIndicatorVisible && + resolvingRecipientDestination == contact.destination + }, + trailingIndicatorTestTag = NEW_CHAT_CONTACT_RESOLVING_INDICATOR_TEST_TAG, + ), + onRecipientClick = { contact -> when { - uiState.isLoading -> { - item { - NewChatLoadingState() - } + isCreatingGroup -> { + onCreateGroupRecipientClick(contact.destination) } - uiState.contacts.isEmpty() || !uiState.hasContactsPermission -> { - item { - NewChatEmptyState() - } + else -> { + onContactClick(contact.destination) + } + } + }, + modifier = modifier, + onLoadMore = onLoadMore, + onPrimaryActionClick = onCreateGroupConfirmed, + onQueryChanged = onQueryChanged, + onRecipientLongClick = { contact -> + when { + isCreatingGroup -> { + onCreateGroupRecipientClick(contact.destination) } else -> { - itemsIndexed( - items = contacts, - key = { _, contact -> contact.id }, - contentType = { _, _ -> - NEW_CHAT_CONTACT_CONTENT_TYPE - }, - ) { index, contact -> - val bottomPadding = when { - index == lastContactIndex -> 0.dp - else -> 2.dp - } - - NewChatContactRow( - modifier = Modifier - .padding(bottom = bottomPadding), - contact = contact, - enabled = contactSelectionEnabled, - isCreateGroupMode = isCreatingGroup, - isSelected = selectedGroupRecipientDestinations.contains( - contact.destination, - ), - onContactClick = onContactClick, - onContactLongClick = onContactLongClick, - onCreateGroupRecipientClick = onCreateGroupRecipientClick, - shape = newChatContactRowShape( - index = index, - totalCount = contacts.size, - ), - showResolvingIndicator = !isCreatingGroup && - isResolvingConversationIndicatorVisible && - resolvingRecipientDestination == contact.destination, - ) - } + onContactLongClick(contact.destination) } } - - if (uiState.isLoadingMore) { - item { - NewChatLoadingMoreState() + }, + topListContent = { + AnimatedVisibility( + visible = !isCreatingGroup, + enter = newGroupButtonEnterTransition(), + exit = newGroupButtonExitTransition(), + ) { + Column( + verticalArrangement = Arrangement.spacedBy(space = 12.dp), + ) { + NewGroupButton( + modifier = Modifier.fillMaxWidth(), + onClick = onCreateGroupClick, + ) + Spacer(modifier = Modifier.height(height = 12.dp)) } } - } - - AnimatedVisibility( - modifier = Modifier - .align(alignment = Alignment.BottomEnd), - visible = showCreateGroupNextButton, - enter = createGroupNextButtonEnterTransition(), - exit = createGroupNextButtonExitTransition(), - ) { - CreateGroupNextButton( - modifier = Modifier - .navigationBarsPadding() - .padding(end = 8.dp, bottom = 8.dp), - enabled = !uiState.isLoading && contactSelectionEnabled, - isLoading = isResolvingConversationIndicatorVisible, - onClick = onCreateGroupConfirmed, - ) - } - } -} - -@Composable -private fun NewChatLoadingState() { - Box( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 32.dp), - contentAlignment = Alignment.Center, - ) { - CircularProgressIndicator() - } + }, + ) } @Composable -private fun NewChatLoadingMoreState() { - Box( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 16.dp), - contentAlignment = Alignment.Center, - ) { - CircularProgressIndicator( - modifier = Modifier.size(size = 20.dp), - strokeWidth = 2.dp, - ) +private fun newChatTitle( + isCreatingGroup: Boolean, +): String { + return when { + isCreatingGroup -> stringResource(id = R.string.conversation_new_group) + else -> stringResource(id = R.string.start_new_conversation) } } -@Composable -private fun NewChatEmptyState() { - Text( - text = stringResource(id = R.string.contact_list_empty_text), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(vertical = 24.dp, horizontal = 4.dp), - ) -} - @Composable private fun NewGroupButton( modifier: Modifier = Modifier, - enabled: Boolean, onClick: () -> Unit, ) { val hapticFeedback = LocalHapticFeedback.current @@ -543,8 +255,7 @@ private fun NewGroupButton( hapticFeedback.performHapticFeedback(HapticFeedbackType.ContextClick) onClick() }, - enabled = enabled, - shape = RoundedCornerShape(size = 18.dp), + shape = androidx.compose.foundation.shape.RoundedCornerShape(size = 18.dp), colors = ButtonDefaults.filledTonalButtonColors( containerColor = MaterialTheme.colorScheme.primary, contentColor = MaterialTheme.colorScheme.onPrimary, @@ -565,279 +276,11 @@ private fun NewGroupButton( } } -@Composable -private fun CreateGroupNextButton( - modifier: Modifier = Modifier, - enabled: Boolean, - isLoading: Boolean, - onClick: () -> Unit, -) { - Button( - modifier = modifier - .animateContentSize( - animationSpec = defaultSpatialAnimationSpec(), - ) - .testTag(NEW_CHAT_CREATE_GROUP_NEXT_BUTTON_TEST_TAG), - onClick = onClick, - enabled = enabled, - shape = RoundedCornerShape(size = 18.dp), - ) { - AnimatedContent( - targetState = isLoading, - transitionSpec = { - nextButtonContentTransform() - }, - label = "createGroupNextButtonContent", - ) { isButtonLoading -> - if (isButtonLoading) { - CircularProgressIndicator( - modifier = Modifier.size(size = 18.dp), - color = MaterialTheme.colorScheme.onPrimary, - strokeWidth = 2.dp, - ) - } else { - Row( - verticalAlignment = Alignment.CenterVertically, - ) { - Text(text = stringResource(id = R.string.next)) - Spacer(modifier = Modifier.size(size = 8.dp)) - Icon( - imageVector = Icons.AutoMirrored.Rounded.ArrowForward, - contentDescription = null, - ) - } - } - } - } -} - -@Composable -private fun NewChatContactRow( - modifier: Modifier = Modifier, - contact: ConversationRecipient, - shape: RoundedCornerShape, - enabled: Boolean, - isCreateGroupMode: Boolean, - isSelected: Boolean, - onContactClick: (String) -> Unit, - onContactLongClick: (String) -> Unit, - onCreateGroupRecipientClick: (String) -> Unit, - showResolvingIndicator: Boolean, -) { - val hapticFeedback = LocalHapticFeedback.current - val selectionTransition = updateTransition( - targetState = isSelected, - label = "newChatContactSelection", - ) - val containerColor by selectionTransition.animateContainerColor() - val primaryTextColor by selectionTransition.animatePrimaryTextColor() - val secondaryTextColor by selectionTransition.animateSecondaryTextColor() - - Row( - modifier = Modifier - .then(other = modifier) - .fillMaxWidth() - .testTag(newChatContactRowTestTag(contactId = contact.id)) - .semantics { - selected = isSelected - } - .background( - color = containerColor, - shape = shape, - ) - .combinedClickable( - enabled = enabled, - onClick = { - hapticFeedback.performHapticFeedback(HapticFeedbackType.ContextClick) - when { - isCreateGroupMode -> { - onCreateGroupRecipientClick(contact.destination) - } - - else -> { - onContactClick(contact.destination) - } - } - }, - onLongClick = { - hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) - when { - isCreateGroupMode -> { - onCreateGroupRecipientClick(contact.destination) - } - - else -> { - onContactLongClick(contact.destination) - } - } - }, - ) - .padding(horizontal = 16.dp, vertical = 14.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - NewChatContactAvatar( - contact = contact, - isSelected = isSelected, - ) - - Column( - modifier = Modifier - .padding(start = 14.dp) - .weight(weight = 1f), - verticalArrangement = Arrangement.spacedBy(space = 2.dp), - ) { - Text( - text = contact.displayName, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - style = MaterialTheme.typography.bodyLarge, - color = primaryTextColor, - ) - - contact.secondaryText?.let { secondaryText -> - Text( - text = secondaryText, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - style = MaterialTheme.typography.bodyMedium, - color = secondaryTextColor, - ) - } - } - - AnimatedVisibility( - visible = showResolvingIndicator, - enter = resolvingIndicatorEnterTransition(), - exit = resolvingIndicatorExitTransition(), - ) { - CircularProgressIndicator( - modifier = Modifier - .size(size = 20.dp) - .testTag(NEW_CHAT_CONTACT_RESOLVING_INDICATOR_TEST_TAG), - strokeWidth = 2.dp, - ) - } - } -} - -private fun newChatContactRowShape( - index: Int, - totalCount: Int, -): RoundedCornerShape { - return when { - totalCount <= 1 -> SingleContactShape - index == 0 -> TopContactShape - index == totalCount - 1 -> BottomContactShape - else -> MiddleContactShape - } -} - -@Composable -private fun NewChatContactAvatar( - contact: ConversationRecipient, - isSelected: Boolean, -) { - val avatarScale by rememberContactAvatarScale( - isSelected = isSelected, - ) - - AnimatedContent( - targetState = isSelected, - transitionSpec = { - contactAvatarContentTransform() - }, - label = "newChatContactAvatar", - ) { isSelectedState -> - Box( - modifier = Modifier.graphicsLayer { - scaleX = avatarScale - scaleY = avatarScale - }, - ) { - when { - isSelectedState -> { - SelectedContactAvatar() - } - - contact.photoUri == null -> { - NewChatContactTextAvatar( - contact = contact, - ) - } - - else -> { - AsyncImage( - model = contact.photoUri, - contentDescription = contact.displayName, - modifier = Modifier - .size(size = 40.dp) - .clip(shape = CircleShape), - ) - } - } - } - } -} - -@Composable -private fun SelectedContactAvatar( - modifier: Modifier = Modifier, -) { - Box( - modifier = modifier - .size(size = 40.dp) - .background( - color = MaterialTheme.colorScheme.primary, - shape = CircleShape, - ), - contentAlignment = Alignment.Center, - ) { - Icon( - imageVector = Icons.Rounded.Check, - contentDescription = null, - tint = MaterialTheme.colorScheme.onPrimary, - ) - } -} - -@Composable -private fun NewChatContactTextAvatar( - modifier: Modifier = Modifier, - contact: ConversationRecipient, -) { - val label = remember(contact.displayName, contact.destination) { - contactAvatarLabel(contact = contact) - } - - Box( - modifier = modifier - .size(size = 40.dp) - .background( - color = MaterialTheme.colorScheme.secondaryContainer, - shape = CircleShape, - ), - contentAlignment = Alignment.Center, - ) { - Text( - text = label, - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSecondaryContainer, - ) - } -} - -private fun contactAvatarLabel(contact: ConversationRecipient): String { - val labelSource = contact.displayName.ifBlank { contact.destination } - val firstCharacter = labelSource.firstOrNull() ?: '?' - - return firstCharacter.uppercaseChar().toString() -} - private fun newGroupButtonEnterTransition(): EnterTransition { return fadeIn( - animationSpec = defaultEffectsAnimationSpec(), + animationSpec = newChatDefaultEffectsAnimationSpec(), ) + slideInVertically( - animationSpec = defaultSpatialAnimationSpec(), + animationSpec = newChatSpatialAnimationSpec(), initialOffsetY = { fullHeight -> -fullHeight / 4 }, @@ -846,220 +289,30 @@ private fun newGroupButtonEnterTransition(): EnterTransition { private fun newGroupButtonExitTransition(): ExitTransition { return fadeOut( - animationSpec = fastEffectsAnimationSpec(), + animationSpec = newChatFastEffectsAnimationSpec(), ) + shrinkVertically( - animationSpec = defaultSpatialAnimationSpec(), - shrinkTowards = Alignment.Top, - ) -} - -private fun createGroupNextButtonEnterTransition(): EnterTransition { - return fadeIn( - animationSpec = defaultEffectsAnimationSpec(), - ) + slideInVertically( - animationSpec = defaultSpatialAnimationSpec(), - initialOffsetY = { fullHeight -> - fullHeight / 2 - }, - ) + scaleIn( - animationSpec = defaultSpatialAnimationSpec(), - initialScale = 0.9f, - ) -} - -private fun createGroupNextButtonExitTransition(): ExitTransition { - return fadeOut( - animationSpec = fastEffectsAnimationSpec(), - ) + slideOutVertically( - animationSpec = defaultSpatialAnimationSpec(), - targetOffsetY = { fullHeight -> - fullHeight / 2 - }, - ) + scaleOut( - animationSpec = defaultSpatialAnimationSpec(), - targetScale = 0.9f, - ) -} - -private fun nextButtonContentTransform(): ContentTransform { - return (fadeIn( - animationSpec = defaultEffectsAnimationSpec(), - ) + scaleIn( - animationSpec = defaultSpatialAnimationSpec(), - initialScale = 0.9f, - )).togetherWith( - fadeOut( - animationSpec = fastEffectsAnimationSpec(), - ) + scaleOut( - animationSpec = defaultSpatialAnimationSpec(), - targetScale = 0.9f, - ), - ) -} - -private fun resolvingIndicatorEnterTransition(): EnterTransition { - return fadeIn( - animationSpec = defaultEffectsAnimationSpec(), - ) + scaleIn( - animationSpec = defaultSpatialAnimationSpec(), - initialScale = 0.8f, - ) -} - -private fun resolvingIndicatorExitTransition(): ExitTransition { - return fadeOut( - animationSpec = fastEffectsAnimationSpec(), - ) + scaleOut( - animationSpec = defaultSpatialAnimationSpec(), - targetScale = 0.8f, - ) -} - -private fun contactAvatarContentTransform(): ContentTransform { - return (fadeIn( - animationSpec = defaultEffectsAnimationSpec(), - ) + scaleIn( - animationSpec = defaultSpatialAnimationSpec(), - initialScale = 0.8f, - )).togetherWith( - fadeOut( - animationSpec = fastEffectsAnimationSpec(), - ) + scaleOut( - animationSpec = defaultSpatialAnimationSpec(), - targetScale = 0.8f, - ), - ) -} - -@Composable -private fun rememberContactAvatarScale( - isSelected: Boolean, -): State { - val selectionTransition = updateTransition( - targetState = isSelected, - label = "newChatContactAvatarScale", - ) - - return selectionTransition.animateFloat( - transitionSpec = { - defaultSpatialAnimationSpec() - }, - label = "newChatContactAvatarScaleValue", - targetValueByState = { isAvatarSelected -> - when { - isAvatarSelected -> 1f - else -> 0.9f - } - }, - ) -} - -@Composable -private fun Transition.animateContainerColor(): State { - return animateColor( - transitionSpec = { - contactSelectionAnimationSpec() - }, - label = "newChatContactContainerColor", - targetValueByState = { isContactSelected -> - when { - isContactSelected -> MaterialTheme.colorScheme.secondaryContainer - else -> MaterialTheme.colorScheme.background - } - }, - ) -} - -@Composable -private fun Transition.animatePrimaryTextColor(): State { - return animateColor( - transitionSpec = { - contactSelectionAnimationSpec() - }, - label = "newChatContactPrimaryTextColor", - targetValueByState = { isContactSelected -> - when { - isContactSelected -> MaterialTheme.colorScheme.onSecondaryContainer - else -> MaterialTheme.colorScheme.onSurface - } - }, - ) -} - -@Composable -private fun Transition.animateSecondaryTextColor(): State { - return animateColor( - transitionSpec = { - contactSelectionAnimationSpec() - }, - label = "newChatContactSecondaryTextColor", - targetValueByState = { isContactSelected -> - when { - isContactSelected -> { - MaterialTheme.colorScheme.onSecondaryContainer.copy(alpha = 0.7f) - } - - else -> { - MaterialTheme.colorScheme.onSurfaceVariant - } - } - }, + animationSpec = newChatSpatialAnimationSpec(), + shrinkTowards = androidx.compose.ui.Alignment.Top, ) } -private fun contactSelectionAnimationSpec(): FiniteAnimationSpec { - return tween( - durationMillis = 200, - easing = FastOutSlowInEasing, - ) -} - -private fun defaultEffectsAnimationSpec(): FiniteAnimationSpec { +private fun newChatDefaultEffectsAnimationSpec(): FiniteAnimationSpec { return tween( durationMillis = 200, easing = LinearOutSlowInEasing, ) } -private fun fastEffectsAnimationSpec(): FiniteAnimationSpec { +private fun newChatFastEffectsAnimationSpec(): FiniteAnimationSpec { return tween( durationMillis = 150, easing = FastOutSlowInEasing, ) } -private fun defaultSpatialAnimationSpec(): FiniteAnimationSpec { +private fun newChatSpatialAnimationSpec(): FiniteAnimationSpec { return spring( dampingRatio = Spring.DampingRatioNoBouncy, stiffness = Spring.StiffnessMediumLow, ) } - -@Composable -private fun NewChatScreenPreviewContent( - uiState: RecipientPickerUiState, - isCreatingGroup: Boolean = false, - isResolvingConversation: Boolean = false, - isResolvingConversationIndicatorVisible: Boolean = false, - resolvingRecipientDestination: String? = null, - selectedGroupRecipientDestinations: ImmutableList = persistentListOf(), -) { - AppTheme { - NewChatScreenContent( - modifier = Modifier.fillMaxSize(), - uiState = uiState, - isCreatingGroup = isCreatingGroup, - isResolvingConversation = isResolvingConversation, - isResolvingConversationIndicatorVisible = isResolvingConversationIndicatorVisible, - onContactClick = {}, - onContactLongClick = {}, - onCreateGroupClick = {}, - onCreateGroupConfirmed = {}, - onCreateGroupRecipientClick = {}, - onLoadMore = {}, - onQueryChanged = {}, - resolvingRecipientDestination = resolvingRecipientDestination, - selectedGroupRecipientDestinations = selectedGroupRecipientDestinations, - ) - } -} diff --git a/src/com/android/messaging/ui/conversation/v2/recipientpicker/RecipientPickerViewModel.kt b/src/com/android/messaging/ui/conversation/v2/recipientpicker/RecipientPickerViewModel.kt index 8ac7715c..9a114ee4 100644 --- a/src/com/android/messaging/ui/conversation/v2/recipientpicker/RecipientPickerViewModel.kt +++ b/src/com/android/messaging/ui/conversation/v2/recipientpicker/RecipientPickerViewModel.kt @@ -1,347 +1,44 @@ package com.android.messaging.ui.conversation.v2.recipientpicker -import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.android.messaging.data.conversation.model.recipient.ConversationRecipient -import com.android.messaging.data.conversation.repository.ConversationRecipientsPage -import com.android.messaging.data.conversation.repository.ConversationRecipientsRepository -import com.android.messaging.di.core.DefaultDispatcher -import com.android.messaging.domain.contacts.usecase.IsReadContactsPermissionGranted +import com.android.messaging.ui.conversation.v2.recipientpicker.delegate.RecipientPickerDelegate import com.android.messaging.ui.conversation.v2.recipientpicker.model.RecipientPickerUiState import dagger.hilt.android.lifecycle.HiltViewModel 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.delay -import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock internal interface RecipientPickerModel { val uiState: StateFlow fun onLoadMore() + fun onExcludedDestinationsChanged(destinations: Set) + fun onQueryChanged(query: String) } @HiltViewModel internal class RecipientPickerViewModel @Inject constructor( - private val conversationRecipientsRepository: ConversationRecipientsRepository, - private val isReadContactsPermissionGranted: IsReadContactsPermissionGranted, - @param:DefaultDispatcher - private val defaultDispatcher: CoroutineDispatcher, - private val savedStateHandle: SavedStateHandle, + private val recipientPickerDelegate: RecipientPickerDelegate, ) : ViewModel(), RecipientPickerModel { - private val queryFlow: StateFlow = savedStateHandle.getStateFlow( - key = SEARCH_QUERY_KEY, - initialValue = "", - ) - - private val _uiState = MutableStateFlow( - RecipientPickerUiState( - query = queryFlow.value, - isLoading = false, - ), - ) - - private var searchSession = RecipientSearchSession( - effectiveQuery = queryFlow.value, - hasCompletedInitialLoad = false, - nextPageOffset = null, - ) - private val searchSessionMutex = Mutex() - - override val uiState = _uiState.asStateFlow() + override val uiState = recipientPickerDelegate.state init { - bindQueryFlow() - } - - private fun bindQueryFlow() { - viewModelScope.launch(defaultDispatcher) { - queryFlow.collectLatest { query -> - handleQueryChanged(query = query) - } - } - } - - private suspend fun handleQueryChanged(query: String) { - if (!isReadContactsPermissionGranted()) { - applyPermissionDeniedState(query = query) - return - } - - startSearch(query = query) + recipientPickerDelegate.bind(scope = viewModelScope) } override fun onLoadMore() { - viewModelScope.launch(defaultDispatcher) { - val loadMoreRequest = createLoadMoreRequest() ?: return@launch - loadMore(request = loadMoreRequest) - } + recipientPickerDelegate.onLoadMore() } - private fun mergeRecipients( - existingRecipients: List, - additionalRecipients: List, - ): ImmutableList { - val seenDestinations = LinkedHashSet() - - return (existingRecipients + additionalRecipients) - .asSequence() - .filter { recipient -> - seenDestinations.add(recipient.destination) - } - .toImmutableList() + override fun onExcludedDestinationsChanged(destinations: Set) { + recipientPickerDelegate.onExcludedDestinationsChanged(destinations = destinations) } override fun onQueryChanged(query: String) { - updateQueryInUiState(query = query) - - if (query != queryFlow.value) { - savedStateHandle[SEARCH_QUERY_KEY] = query - } - } - - private suspend fun startSearch(query: String) { - applySearchStartedState() - delay(timeMillis = SEARCH_DEBOUNCE_MILLIS) - - val initialSearchResult = resolveInitialSearch(query = query) - updateSearchSession { currentSearchSession -> - currentSearchSession.copy( - effectiveQuery = initialSearchResult.effectiveQuery, - hasCompletedInitialLoad = true, - nextPageOffset = initialSearchResult.page.nextOffset, - ) - } - - applyInitialSearchResult(result = initialSearchResult) - } - - private suspend fun applyPermissionDeniedState(query: String) { - updateSearchSession { currentSearchSession -> - currentSearchSession.copy( - effectiveQuery = query, - nextPageOffset = null, - ) - } - - _uiState.update { currentState -> - currentState.copy( - canLoadMore = false, - contacts = persistentListOf(), - hasContactsPermission = false, - isLoading = false, - isLoadingMore = false, - ) - } - } - - private suspend fun applySearchStartedState() { - val shouldShowInitialLoader = searchSessionMutex.withLock { - !searchSession.hasCompletedInitialLoad - } - - _uiState.update { currentState -> - currentState.copy( - canLoadMore = false, - hasContactsPermission = true, - isLoading = shouldShowInitialLoader, - isLoadingMore = false, - ) - } - } - - private suspend fun resolveInitialSearch(query: String): InitialSearchResult { - val requestedPage = loadRecipientsPage( - query = query, - offset = 0, - ) - - val shouldUseRequestedPage = shouldUseRequestedPage( - query = query, - page = requestedPage, - ) - - if (shouldUseRequestedPage) { - return InitialSearchResult( - effectiveQuery = query, - page = requestedPage, - ) - } - - val defaultPage = loadRecipientsPage( - query = "", - offset = 0, - ) - - return InitialSearchResult( - effectiveQuery = "", - page = defaultPage, - ) - } - - private fun shouldUseRequestedPage( - query: String, - page: ConversationRecipientsPage, - ): Boolean { - return query.isBlank() || page.recipients.isNotEmpty() - } - - private suspend fun loadRecipientsPage( - query: String, - offset: Int, - ): ConversationRecipientsPage { - return conversationRecipientsRepository - .searchRecipients( - query = query, - offset = offset, - ) - .first() - } - - private fun applyInitialSearchResult(result: InitialSearchResult) { - _uiState.update { currentState -> - currentState.copy( - contacts = result.page.recipients, - canLoadMore = result.page.nextOffset != null, - hasContactsPermission = true, - isLoading = false, - isLoadingMore = false, - ) - } - } - - private suspend fun createLoadMoreRequest(): LoadMoreRequest? { - val currentUiState = _uiState.value - - if (currentUiState.isLoading || currentUiState.isLoadingMore) { - return null - } - - if (!currentUiState.hasContactsPermission) { - return null - } - - return searchSessionMutex.withLock { - val nextPageOffset = searchSession.nextPageOffset ?: return@withLock null - - LoadMoreRequest( - effectiveQuery = searchSession.effectiveQuery, - inputQuery = currentUiState.query, - offset = nextPageOffset, - ) - } - } - - private suspend fun loadMore(request: LoadMoreRequest) { - applyLoadMoreStartedState() - - val nextPage = loadRecipientsPage( - query = request.effectiveQuery, - offset = request.offset, - ) - - if (!isLoadMoreRequestCurrent(request = request)) { - applyLoadMoreStoppedState() - return - } - - updateSearchSession { currentSearchSession -> - currentSearchSession.copy( - nextPageOffset = nextPage.nextOffset, - ) - } - - applyLoadMoreResult(page = nextPage) - } - - private fun applyLoadMoreStartedState() { - _uiState.update { currentState -> - currentState.copy( - isLoadingMore = true, - ) - } - } - - private suspend fun isLoadMoreRequestCurrent(request: LoadMoreRequest): Boolean { - val currentEffectiveQuery = searchSessionMutex.withLock { - searchSession.effectiveQuery - } - - return currentEffectiveQuery == request.effectiveQuery && - _uiState.value.query == request.inputQuery - } - - private fun applyLoadMoreStoppedState() { - _uiState.update { currentState -> - currentState.copy( - isLoadingMore = false, - ) - } - } - - private fun applyLoadMoreResult(page: ConversationRecipientsPage) { - _uiState.update { currentState -> - currentState.copy( - contacts = mergeRecipients( - existingRecipients = currentState.contacts, - additionalRecipients = page.recipients, - ), - canLoadMore = page.nextOffset != null, - isLoadingMore = false, - ) - } - } - - private fun updateQueryInUiState(query: String) { - _uiState.update { currentState -> - currentState.copy( - query = query, - ) - } - } - - private suspend fun updateSearchSession( - transform: (RecipientSearchSession) -> RecipientSearchSession, - ) { - searchSessionMutex.withLock { - searchSession = transform(searchSession) - } - } - - private data class InitialSearchResult( - val effectiveQuery: String, - val page: ConversationRecipientsPage, - ) - - private data class LoadMoreRequest( - val effectiveQuery: String, - val inputQuery: String, - val offset: Int, - ) - - private data class RecipientSearchSession( - val effectiveQuery: String, - val hasCompletedInitialLoad: Boolean, - val nextPageOffset: Int?, - ) - - private companion object { - private const val SEARCH_DEBOUNCE_MILLIS = 150L - private const val SEARCH_QUERY_KEY = "search_query" + recipientPickerDelegate.onQueryChanged(query = query) } } diff --git a/src/com/android/messaging/ui/conversation/v2/recipientpicker/RecipientSelectionContent.kt b/src/com/android/messaging/ui/conversation/v2/recipientpicker/RecipientSelectionContent.kt new file mode 100644 index 00000000..c024711f --- /dev/null +++ b/src/com/android/messaging/ui/conversation/v2/recipientpicker/RecipientSelectionContent.kt @@ -0,0 +1,843 @@ +@file:OptIn( + ExperimentalMaterial3Api::class, +) + +package com.android.messaging.ui.conversation.v2.recipientpicker + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.ContentTransform +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.animation.animateColor +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.FiniteAnimationSpec +import androidx.compose.animation.core.LinearOutSlowInEasing +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.Transition +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.animation.core.updateTransition +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.animation.togetherWith +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 +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.ArrowForward +import androidx.compose.material.icons.rounded.Check +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.State +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.selected +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import com.android.messaging.R +import com.android.messaging.data.conversation.model.recipient.ConversationRecipient + +private val contactCornerRadius = 18.dp +private val contactMiddleCornerRadius = 2.dp +private val searchFieldShape = RoundedCornerShape(size = 22.dp) +private val topContactShape = RoundedCornerShape( + topStart = contactCornerRadius, + topEnd = contactCornerRadius, + bottomStart = contactMiddleCornerRadius, + bottomEnd = contactMiddleCornerRadius, +) +private val bottomContactShape = RoundedCornerShape( + topStart = contactMiddleCornerRadius, + topEnd = contactMiddleCornerRadius, + bottomStart = contactCornerRadius, + bottomEnd = contactCornerRadius, +) +private val middleContactShape = RoundedCornerShape(size = contactMiddleCornerRadius) +private val singleContactShape = RoundedCornerShape(size = contactCornerRadius) + +private const val CONTACTS_LOAD_MORE_THRESHOLD = 10 +private const val RECIPIENT_CONTACT_CONTENT_TYPE = "recipient_contact" + +@Composable +internal fun RecipientSelectionContent( + uiState: RecipientSelectionContentUiState, + strings: RecipientSelectionStrings, + rowDecorators: RecipientSelectionRowDecorators, + onRecipientClick: (ConversationRecipient) -> Unit, + modifier: Modifier = Modifier, + onLoadMore: () -> Unit = {}, + onPrimaryActionClick: () -> Unit = {}, + onQueryChanged: (String) -> Unit = {}, + onRecipientLongClick: ((ConversationRecipient) -> Unit)? = null, + topListContent: (@Composable () -> Unit)? = null, +) { + Surface( + modifier = modifier, + color = MaterialTheme.colorScheme.surfaceVariant, + ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 16.dp), + ) { + Spacer(modifier = Modifier.height(height = 16.dp)) + + RecipientSelectionQueryField( + query = uiState.picker.query, + enabled = uiState.isQueryEnabled, + prefixText = strings.queryPrefixText, + placeholderText = strings.queryPlaceholderText, + onQueryChanged = onQueryChanged, + ) + + Spacer(modifier = Modifier.height(height = 12.dp)) + + RecipientSelectionContactsContent( + modifier = Modifier.fillMaxSize(), + uiState = uiState, + rowDecorators = rowDecorators, + onLoadMore = onLoadMore, + onPrimaryActionClick = onPrimaryActionClick, + onRecipientClick = onRecipientClick, + onRecipientLongClick = onRecipientLongClick, + topListContent = topListContent, + ) + } + } +} + +@Composable +private fun RecipientSelectionQueryField( + query: String, + enabled: Boolean, + prefixText: String, + placeholderText: String, + onQueryChanged: (String) -> Unit, +) { + TextField( + modifier = Modifier.fillMaxWidth(), + value = query, + onValueChange = onQueryChanged, + enabled = enabled, + singleLine = true, + shape = searchFieldShape, + colors = TextFieldDefaults.colors( + focusedContainerColor = MaterialTheme.colorScheme.surface, + unfocusedContainerColor = MaterialTheme.colorScheme.surface, + disabledContainerColor = MaterialTheme.colorScheme.surface, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + disabledIndicatorColor = Color.Transparent, + focusedTextColor = MaterialTheme.colorScheme.onSurface, + unfocusedTextColor = MaterialTheme.colorScheme.onSurface, + disabledTextColor = MaterialTheme.colorScheme.onSurface, + focusedPlaceholderColor = MaterialTheme.colorScheme.onSurfaceVariant, + unfocusedPlaceholderColor = MaterialTheme.colorScheme.onSurfaceVariant, + disabledPlaceholderColor = MaterialTheme.colorScheme.onSurfaceVariant, + focusedPrefixColor = MaterialTheme.colorScheme.onSurfaceVariant, + unfocusedPrefixColor = MaterialTheme.colorScheme.onSurfaceVariant, + disabledPrefixColor = MaterialTheme.colorScheme.onSurfaceVariant, + ), + prefix = { + Text( + modifier = Modifier.padding(end = 12.dp), + text = prefixText, + style = MaterialTheme.typography.bodyLarge, + ) + }, + placeholder = { + Text(text = placeholderText) + }, + ) +} + +@Composable +private fun RecipientSelectionContactsContent( + uiState: RecipientSelectionContentUiState, + rowDecorators: RecipientSelectionRowDecorators, + onLoadMore: () -> Unit, + onPrimaryActionClick: () -> Unit, + onRecipientClick: (ConversationRecipient) -> Unit, + onRecipientLongClick: ((ConversationRecipient) -> Unit)?, + modifier: Modifier = Modifier, + topListContent: (@Composable () -> Unit)? = null, +) { + val pickerUiState = uiState.picker + val primaryAction = uiState.primaryAction + val lastContactIndex = pickerUiState.contacts.lastIndex + val listState = rememberLazyListState() + + val animatedListBottomPadding by animateDpAsState( + targetValue = when { + primaryAction != null -> 100.dp + else -> 16.dp + }, + animationSpec = recipientSelectionSpatialAnimationSpec(), + label = "recipientSelectionListBottomPadding", + ) + + LaunchedEffect( + listState, + pickerUiState.canLoadMore, + pickerUiState.isLoading, + pickerUiState.isLoadingMore, + pickerUiState.contacts.size, + ) { + snapshotFlow { + val lastVisibleIndex = listState + .layoutInfo + .visibleItemsInfo + .lastOrNull() + ?.index + ?: -1 + + lastVisibleIndex >= lastContactIndex - CONTACTS_LOAD_MORE_THRESHOLD + }.collect { shouldLoadMore -> + if ( + shouldLoadMore && + pickerUiState.canLoadMore && + !pickerUiState.isLoading && + !pickerUiState.isLoadingMore + ) { + onLoadMore() + } + } + } + + Box(modifier = modifier) { + LazyColumn( + modifier = Modifier.fillMaxSize(), + state = listState, + contentPadding = PaddingValues(bottom = animatedListBottomPadding), + ) { + topListContent?.let { + item { + topListContent() + } + } + + when { + pickerUiState.isLoading -> { + item { + RecipientSelectionLoadingState() + } + } + + pickerUiState.contacts.isEmpty() || !pickerUiState.hasContactsPermission -> { + item { + RecipientSelectionEmptyState() + } + } + + else -> { + itemsIndexed( + items = pickerUiState.contacts, + key = { _, contact -> contact.id }, + contentType = { _, _ -> RECIPIENT_CONTACT_CONTENT_TYPE }, + ) { index, contact -> + val bottomPadding = when { + index == lastContactIndex -> 0.dp + else -> 2.dp + } + + RecipientSelectionContactRow( + modifier = Modifier.padding(bottom = bottomPadding), + contact = contact, + enabled = primaryAction?.isLoading != true, + isSelected = uiState.selectedRecipientDestinations.contains( + contact.destination, + ), + onClick = { + onRecipientClick(contact) + }, + onLongClick = onRecipientLongClick?.let { callback -> + { + callback(contact) + } + }, + rowTestTag = rowDecorators.recipientRowTestTag(contact), + shape = recipientSelectionContactRowShape( + index = index, + totalCount = pickerUiState.contacts.size, + ), + showTrailingIndicator = rowDecorators + .showRecipientTrailingIndicator( + contact, + ), + trailingIndicatorTestTag = rowDecorators.trailingIndicatorTestTag, + ) + } + } + } + + if (pickerUiState.isLoadingMore) { + item { + RecipientSelectionLoadingMoreState() + } + } + } + + AnimatedVisibility( + modifier = Modifier + .align(alignment = Alignment.BottomEnd), + visible = primaryAction != null, + enter = recipientSelectionPrimaryActionEnterTransition(), + exit = recipientSelectionPrimaryActionExitTransition(), + ) { + RecipientSelectionPrimaryActionButton( + modifier = Modifier + .navigationBarsPadding() + .padding(end = 8.dp, bottom = 8.dp), + enabled = primaryAction?.isEnabled ?: false, + isLoading = primaryAction?.isLoading ?: false, + text = primaryAction?.text.orEmpty(), + testTag = primaryAction?.testTag, + onClick = onPrimaryActionClick, + ) + } + } +} + +@Composable +private fun RecipientSelectionLoadingState() { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 32.dp), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } +} + +@Composable +private fun RecipientSelectionLoadingMoreState() { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 16.dp), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator( + modifier = Modifier + .size(size = 20.dp), + strokeWidth = 2.dp, + ) + } +} + +@Composable +private fun RecipientSelectionEmptyState() { + Text( + modifier = Modifier + .padding(vertical = 24.dp, horizontal = 4.dp), + text = stringResource(id = R.string.contact_list_empty_text), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) +} + +@Composable +private fun RecipientSelectionPrimaryActionButton( + enabled: Boolean, + isLoading: Boolean, + text: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + testTag: String? = null, +) { + val taggedModifier = when { + testTag != null -> modifier.testTag(testTag) + else -> modifier + } + + Button( + modifier = taggedModifier + .animateContentSize( + animationSpec = recipientSelectionSpatialAnimationSpec(), + ), + onClick = onClick, + enabled = enabled, + shape = RoundedCornerShape(size = 18.dp), + ) { + AnimatedContent( + targetState = isLoading, + transitionSpec = { + recipientSelectionPrimaryActionContentTransform() + }, + label = "recipientSelectionPrimaryActionButtonContent", + ) { isButtonLoading -> + when { + isButtonLoading -> { + CircularProgressIndicator( + modifier = Modifier.size(size = 18.dp), + color = MaterialTheme.colorScheme.onPrimary, + strokeWidth = 2.dp, + ) + } + + else -> { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + Text(text = text) + + Spacer(modifier = Modifier.size(size = 8.dp)) + + Icon( + imageVector = Icons.AutoMirrored.Rounded.ArrowForward, + contentDescription = null, + ) + } + } + } + } + } +} + +@Composable +private fun RecipientSelectionContactRow( + contact: ConversationRecipient, + enabled: Boolean, + isSelected: Boolean, + onClick: () -> Unit, + shape: RoundedCornerShape, + rowTestTag: String, + modifier: Modifier = Modifier, + onLongClick: (() -> Unit)? = null, + showTrailingIndicator: Boolean = false, + trailingIndicatorTestTag: String? = null, +) { + val hapticFeedback = LocalHapticFeedback.current + val selectionTransition = updateTransition( + targetState = isSelected, + label = "recipientSelectionContactSelection", + ) + + val containerColor by selectionTransition.animateContainerColor() + val primaryTextColor by selectionTransition.animatePrimaryTextColor() + val secondaryTextColor by selectionTransition.animateSecondaryTextColor() + + Row( + modifier = Modifier + .then(other = modifier) + .fillMaxWidth() + .testTag(rowTestTag) + .semantics { + selected = isSelected + } + .background( + color = containerColor, + shape = shape, + ) + .combinedClickable( + enabled = enabled, + onClick = { + hapticFeedback.performHapticFeedback(HapticFeedbackType.ContextClick) + onClick() + }, + onLongClick = onLongClick?.let { callback -> + { + hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) + callback() + } + }, + ) + .padding(horizontal = 16.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + RecipientSelectionContactAvatar( + contact = contact, + isSelected = isSelected, + ) + + Column( + modifier = Modifier + .padding(start = 14.dp) + .weight(weight = 1f), + verticalArrangement = Arrangement.spacedBy(space = 2.dp), + ) { + Text( + text = contact.displayName, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.bodyLarge, + color = primaryTextColor, + ) + + contact.secondaryText?.let { secondaryText -> + Text( + text = secondaryText, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.bodyMedium, + color = secondaryTextColor, + ) + } + } + + AnimatedVisibility( + visible = showTrailingIndicator, + enter = recipientSelectionTrailingIndicatorEnterTransition(), + exit = recipientSelectionTrailingIndicatorExitTransition(), + ) { + CircularProgressIndicator( + modifier = when { + trailingIndicatorTestTag != null -> { + Modifier + .size(size = 20.dp) + .testTag(trailingIndicatorTestTag) + } + + else -> { + Modifier + .size(size = 20.dp) + } + }, + strokeWidth = 2.dp, + ) + } + } +} + +private fun recipientSelectionContactRowShape( + index: Int, + totalCount: Int, +): RoundedCornerShape { + return when { + totalCount <= 1 -> singleContactShape + index == 0 -> topContactShape + index == totalCount - 1 -> bottomContactShape + else -> middleContactShape + } +} + +@Composable +private fun RecipientSelectionContactAvatar( + contact: ConversationRecipient, + isSelected: Boolean, +) { + val avatarScale by rememberRecipientSelectionContactAvatarScale( + isSelected = isSelected, + ) + + AnimatedContent( + targetState = isSelected, + transitionSpec = { + recipientSelectionAvatarContentTransform() + }, + label = "recipientSelectionContactAvatar", + ) { isSelectedState -> + Box( + modifier = Modifier.graphicsLayer { + scaleX = avatarScale + scaleY = avatarScale + }, + ) { + when { + isSelectedState -> { + RecipientSelectionSelectedAvatar() + } + + contact.photoUri == null -> { + RecipientSelectionTextAvatar(contact = contact) + } + + else -> { + AsyncImage( + model = contact.photoUri, + contentDescription = contact.displayName, + modifier = Modifier + .size(size = 40.dp) + .clip(shape = CircleShape), + ) + } + } + } + } +} + +@Composable +private fun RecipientSelectionSelectedAvatar( + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier + .size(size = 40.dp) + .background( + color = MaterialTheme.colorScheme.primary, + shape = CircleShape, + ), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Rounded.Check, + contentDescription = null, + tint = MaterialTheme.colorScheme.onPrimary, + ) + } +} + +@Composable +private fun RecipientSelectionTextAvatar( + contact: ConversationRecipient, + modifier: Modifier = Modifier, +) { + val label = remember(contact.displayName, contact.destination) { + recipientSelectionAvatarLabel(contact = contact) + } + + Box( + modifier = modifier + .size(size = 40.dp) + .background( + color = MaterialTheme.colorScheme.secondaryContainer, + shape = CircleShape, + ), + contentAlignment = Alignment.Center, + ) { + Text( + text = label, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSecondaryContainer, + ) + } +} + +private fun recipientSelectionAvatarLabel( + contact: ConversationRecipient, +): String { + val labelSource = contact.displayName.ifBlank { contact.destination } + val firstCharacter = labelSource.firstOrNull() ?: '?' + + return firstCharacter.uppercaseChar().toString() +} + +private fun recipientSelectionPrimaryActionEnterTransition(): EnterTransition { + return fadeIn( + animationSpec = recipientSelectionDefaultEffectsAnimationSpec(), + ) + slideInVertically( + animationSpec = recipientSelectionSpatialAnimationSpec(), + initialOffsetY = { fullHeight -> + fullHeight / 2 + }, + ) + scaleIn( + animationSpec = recipientSelectionSpatialAnimationSpec(), + initialScale = 0.9f, + ) +} + +private fun recipientSelectionPrimaryActionExitTransition(): ExitTransition { + return fadeOut( + animationSpec = recipientSelectionFastEffectsAnimationSpec(), + ) + slideOutVertically( + animationSpec = recipientSelectionSpatialAnimationSpec(), + targetOffsetY = { fullHeight -> + fullHeight / 2 + }, + ) + scaleOut( + animationSpec = recipientSelectionSpatialAnimationSpec(), + targetScale = 0.9f, + ) +} + +private fun recipientSelectionPrimaryActionContentTransform(): ContentTransform { + return ( + fadeIn( + animationSpec = recipientSelectionDefaultEffectsAnimationSpec(), + ) + scaleIn( + animationSpec = recipientSelectionSpatialAnimationSpec(), + initialScale = 0.9f, + ) + ).togetherWith( + fadeOut( + animationSpec = recipientSelectionFastEffectsAnimationSpec(), + ) + scaleOut( + animationSpec = recipientSelectionSpatialAnimationSpec(), + targetScale = 0.9f, + ), + ) +} + +private fun recipientSelectionTrailingIndicatorEnterTransition(): EnterTransition { + return fadeIn( + animationSpec = recipientSelectionDefaultEffectsAnimationSpec(), + ) + scaleIn( + animationSpec = recipientSelectionSpatialAnimationSpec(), + initialScale = 0.8f, + ) +} + +private fun recipientSelectionTrailingIndicatorExitTransition(): ExitTransition { + return fadeOut( + animationSpec = recipientSelectionFastEffectsAnimationSpec(), + ) + scaleOut( + animationSpec = recipientSelectionSpatialAnimationSpec(), + targetScale = 0.8f, + ) +} + +private fun recipientSelectionAvatarContentTransform(): ContentTransform { + return ( + fadeIn( + animationSpec = recipientSelectionDefaultEffectsAnimationSpec(), + ) + scaleIn( + animationSpec = recipientSelectionSpatialAnimationSpec(), + initialScale = 0.8f, + ) + ).togetherWith( + fadeOut( + animationSpec = recipientSelectionFastEffectsAnimationSpec(), + ) + scaleOut( + animationSpec = recipientSelectionSpatialAnimationSpec(), + targetScale = 0.8f, + ), + ) +} + +@Composable +private fun rememberRecipientSelectionContactAvatarScale( + isSelected: Boolean, +): State { + val selectionTransition = updateTransition( + targetState = isSelected, + label = "recipientSelectionContactAvatarScale", + ) + + return selectionTransition.animateFloat( + transitionSpec = { + recipientSelectionSpatialAnimationSpec() + }, + label = "recipientSelectionContactAvatarScaleValue", + targetValueByState = { isAvatarSelected -> + when { + isAvatarSelected -> 1f + else -> 0.9f + } + }, + ) +} + +@Composable +private fun Transition.animateContainerColor(): State { + return animateColor( + transitionSpec = { + recipientSelectionSelectionAnimationSpec() + }, + label = "recipientSelectionContactContainerColor", + targetValueByState = { isContactSelected -> + when { + isContactSelected -> MaterialTheme.colorScheme.secondaryContainer + else -> MaterialTheme.colorScheme.background + } + }, + ) +} + +@Composable +private fun Transition.animatePrimaryTextColor(): State { + return animateColor( + transitionSpec = { + recipientSelectionSelectionAnimationSpec() + }, + label = "recipientSelectionContactPrimaryTextColor", + targetValueByState = { isContactSelected -> + when { + isContactSelected -> MaterialTheme.colorScheme.onSecondaryContainer + else -> MaterialTheme.colorScheme.onSurface + } + }, + ) +} + +@Composable +private fun Transition.animateSecondaryTextColor(): State { + return animateColor( + transitionSpec = { + recipientSelectionSelectionAnimationSpec() + }, + label = "recipientSelectionContactSecondaryTextColor", + targetValueByState = { isContactSelected -> + when { + isContactSelected -> { + MaterialTheme.colorScheme.onSecondaryContainer.copy(alpha = 0.7f) + } + + else -> MaterialTheme.colorScheme.onSurfaceVariant + } + }, + ) +} + +private fun recipientSelectionSelectionAnimationSpec(): FiniteAnimationSpec { + return tween( + durationMillis = 200, + easing = FastOutSlowInEasing, + ) +} + +private fun recipientSelectionDefaultEffectsAnimationSpec(): FiniteAnimationSpec { + return tween( + durationMillis = 200, + easing = LinearOutSlowInEasing, + ) +} + +private fun recipientSelectionFastEffectsAnimationSpec(): FiniteAnimationSpec { + return tween( + durationMillis = 150, + easing = FastOutSlowInEasing, + ) +} + +private fun recipientSelectionSpatialAnimationSpec(): FiniteAnimationSpec { + return spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = Spring.StiffnessMediumLow, + ) +} diff --git a/src/com/android/messaging/ui/conversation/v2/recipientpicker/RecipientSelectionContentUiState.kt b/src/com/android/messaging/ui/conversation/v2/recipientpicker/RecipientSelectionContentUiState.kt new file mode 100644 index 00000000..5fd871a0 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/v2/recipientpicker/RecipientSelectionContentUiState.kt @@ -0,0 +1,35 @@ +package com.android.messaging.ui.conversation.v2.recipientpicker + +import androidx.compose.runtime.Immutable +import com.android.messaging.data.conversation.model.recipient.ConversationRecipient +import com.android.messaging.ui.conversation.v2.recipientpicker.model.RecipientPickerUiState +import kotlinx.collections.immutable.ImmutableSet +import kotlinx.collections.immutable.persistentSetOf + +@Immutable +internal data class RecipientSelectionContentUiState( + val picker: RecipientPickerUiState = RecipientPickerUiState(), + val primaryAction: RecipientSelectionPrimaryActionUiState? = null, + val selectedRecipientDestinations: ImmutableSet = persistentSetOf(), + val isQueryEnabled: Boolean = true, +) + +@Immutable +internal data class RecipientSelectionPrimaryActionUiState( + val text: String, + val isEnabled: Boolean = false, + val isLoading: Boolean = false, + val testTag: String? = null, +) + +@Immutable +internal data class RecipientSelectionStrings( + val queryPrefixText: String, + val queryPlaceholderText: String, +) + +internal data class RecipientSelectionRowDecorators( + val recipientRowTestTag: (ConversationRecipient) -> String, + val showRecipientTrailingIndicator: (ConversationRecipient) -> Boolean = { false }, + val trailingIndicatorTestTag: String? = null, +) diff --git a/src/com/android/messaging/ui/conversation/v2/recipientpicker/delegate/RecipientPickerDelegate.kt b/src/com/android/messaging/ui/conversation/v2/recipientpicker/delegate/RecipientPickerDelegate.kt new file mode 100644 index 00000000..155cae55 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/v2/recipientpicker/delegate/RecipientPickerDelegate.kt @@ -0,0 +1,408 @@ +package com.android.messaging.ui.conversation.v2.recipientpicker.delegate + +import androidx.lifecycle.SavedStateHandle +import com.android.messaging.data.conversation.model.recipient.ConversationRecipient +import com.android.messaging.data.conversation.repository.ConversationRecipientsPage +import com.android.messaging.data.conversation.repository.ConversationRecipientsRepository +import com.android.messaging.di.core.DefaultDispatcher +import com.android.messaging.domain.contacts.usecase.IsReadContactsPermissionGranted +import com.android.messaging.ui.conversation.v2.recipientpicker.model.RecipientPickerUiState +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.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +internal interface RecipientPickerDelegate { + val state: StateFlow + + fun bind(scope: CoroutineScope) + + fun onLoadMore() + + fun onExcludedDestinationsChanged(destinations: Set) + + fun onQueryChanged(query: String) +} + +internal class RecipientPickerDelegateImpl @Inject constructor( + private val conversationRecipientsRepository: ConversationRecipientsRepository, + private val isReadContactsPermissionGranted: IsReadContactsPermissionGranted, + private val savedStateHandle: SavedStateHandle, + @param:DefaultDispatcher + private val defaultDispatcher: CoroutineDispatcher, +) : RecipientPickerDelegate { + + private val queryFlow = MutableStateFlow( + value = savedStateHandle.get(SEARCH_QUERY_KEY).orEmpty(), + ) + private val excludedDestinationsFlow = MutableStateFlow>( + value = emptySet(), + ) + + private val _state = MutableStateFlow( + value = RecipientPickerUiState( + query = queryFlow.value, + isLoading = false, + ), + ) + + override val state = _state.asStateFlow() + + private var boundScope: CoroutineScope? = null + + private var searchSession = RecipientSearchSession( + effectiveQuery = queryFlow.value, + hasCompletedInitialLoad = false, + nextPageOffset = null, + ) + + private val searchSessionMutex = Mutex() + + override fun bind(scope: CoroutineScope) { + if (boundScope != null) { + return + } + + boundScope = scope + + scope.launch(defaultDispatcher) { + combine( + queryFlow, + excludedDestinationsFlow, + ) { query, excludedDestinations -> + SearchInputs( + query = query, + excludedDestinations = excludedDestinations, + ) + }.collectLatest { searchInputs -> + handleSearchInputsChanged(searchInputs = searchInputs) + } + } + } + + override fun onLoadMore() { + val scope = boundScope ?: return + + scope.launch(defaultDispatcher) { + val loadMoreRequest = createLoadMoreRequest() ?: return@launch + loadMore(request = loadMoreRequest) + } + } + + override fun onExcludedDestinationsChanged(destinations: Set) { + val normalizedDestinations = destinations + .asSequence() + .map { it.trim() } + .filter { it.isNotEmpty() } + .toSet() + + excludedDestinationsFlow.value = normalizedDestinations + } + + override fun onQueryChanged(query: String) { + updateQueryInState(query = query) + + if (query != queryFlow.value) { + queryFlow.value = query + savedStateHandle[SEARCH_QUERY_KEY] = query + } + } + + private suspend fun handleSearchInputsChanged(searchInputs: SearchInputs) { + if (!isReadContactsPermissionGranted()) { + applyPermissionDeniedState(query = searchInputs.query) + return + } + + startSearch(searchInputs = searchInputs) + } + + private fun mergeRecipients( + existingRecipients: List, + additionalRecipients: List, + ): ImmutableList { + val seenDestinations = LinkedHashSet() + + return (existingRecipients + additionalRecipients) + .asSequence() + .filter { recipient -> + seenDestinations.add(recipient.destination) + } + .toImmutableList() + } + + private suspend fun startSearch(searchInputs: SearchInputs) { + applySearchStartedState() + delay(timeMillis = SEARCH_DEBOUNCE_MILLIS) + + val initialSearchResult = resolveInitialSearch(searchInputs = searchInputs) + updateSearchSession { currentSearchSession -> + currentSearchSession.copy( + effectiveQuery = initialSearchResult.effectiveQuery, + hasCompletedInitialLoad = true, + nextPageOffset = initialSearchResult.page.nextOffset, + ) + } + + applyInitialSearchResult(result = initialSearchResult) + } + + private suspend fun applyPermissionDeniedState(query: String) { + updateSearchSession { currentSearchSession -> + currentSearchSession.copy( + effectiveQuery = query, + nextPageOffset = null, + ) + } + + _state.update { currentState -> + currentState.copy( + canLoadMore = false, + contacts = persistentListOf(), + hasContactsPermission = false, + isLoading = false, + isLoadingMore = false, + ) + } + } + + private suspend fun applySearchStartedState() { + val shouldShowInitialLoader = searchSessionMutex.withLock { + !searchSession.hasCompletedInitialLoad + } + + _state.update { currentState -> + currentState.copy( + canLoadMore = false, + hasContactsPermission = true, + isLoading = shouldShowInitialLoader, + isLoadingMore = false, + ) + } + } + + private suspend fun resolveInitialSearch( + searchInputs: SearchInputs, + ): InitialSearchResult { + val requestedPage = loadRecipientsPage( + query = searchInputs.query, + offset = 0, + excludedDestinations = searchInputs.excludedDestinations, + ) + + if (shouldUseRequestedPage(query = searchInputs.query, page = requestedPage)) { + return InitialSearchResult( + effectiveQuery = searchInputs.query, + page = requestedPage, + ) + } + + val defaultPage = loadRecipientsPage( + query = "", + offset = 0, + excludedDestinations = searchInputs.excludedDestinations, + ) + + return InitialSearchResult( + effectiveQuery = "", + page = defaultPage, + ) + } + + private fun shouldUseRequestedPage( + query: String, + page: ConversationRecipientsPage, + ): Boolean { + return query.isBlank() || page.recipients.isNotEmpty() + } + + private suspend fun loadRecipientsPage( + query: String, + offset: Int, + excludedDestinations: Set, + ): ConversationRecipientsPage { + var nextOffset: Int? = offset + val visibleRecipients = mutableListOf() + + while (nextOffset != null) { + val rawPage = conversationRecipientsRepository + .searchRecipients( + query = query, + offset = nextOffset, + ) + .first() + + visibleRecipients.addAll( + rawPage.recipients.filterNot { recipient -> + recipient.destination in excludedDestinations + }, + ) + + if (visibleRecipients.isNotEmpty() || rawPage.nextOffset == null) { + return ConversationRecipientsPage( + recipients = visibleRecipients.toImmutableList(), + nextOffset = rawPage.nextOffset, + ) + } + + nextOffset = rawPage.nextOffset + } + + return ConversationRecipientsPage( + recipients = persistentListOf(), + nextOffset = null, + ) + } + + private fun applyInitialSearchResult(result: InitialSearchResult) { + _state.update { currentState -> + currentState.copy( + contacts = result.page.recipients, + canLoadMore = result.page.nextOffset != null, + hasContactsPermission = true, + isLoading = false, + isLoadingMore = false, + ) + } + } + + private suspend fun createLoadMoreRequest(): LoadMoreRequest? { + val currentState = _state.value + + return when { + currentState.isLoading || currentState.isLoadingMore -> null + !currentState.hasContactsPermission -> null + + else -> { + searchSessionMutex.withLock { + val nextPageOffset = searchSession.nextPageOffset ?: return@withLock null + + LoadMoreRequest( + effectiveQuery = searchSession.effectiveQuery, + inputQuery = currentState.query, + excludedDestinations = excludedDestinationsFlow.value, + offset = nextPageOffset, + ) + } + } + } + } + + private suspend fun loadMore(request: LoadMoreRequest) { + applyLoadMoreStartedState() + + val nextPage = loadRecipientsPage( + query = request.effectiveQuery, + offset = request.offset, + excludedDestinations = request.excludedDestinations, + ) + + if (!isLoadMoreRequestCurrent(request = request)) { + applyLoadMoreStoppedState() + return + } + + updateSearchSession { currentSearchSession -> + currentSearchSession.copy( + nextPageOffset = nextPage.nextOffset, + ) + } + + applyLoadMoreResult(page = nextPage) + } + + private fun applyLoadMoreStartedState() { + _state.update { currentState -> + currentState.copy( + isLoadingMore = true, + ) + } + } + + private suspend fun isLoadMoreRequestCurrent(request: LoadMoreRequest): Boolean { + val currentEffectiveQuery = searchSessionMutex.withLock { + searchSession.effectiveQuery + } + + return currentEffectiveQuery == request.effectiveQuery && + _state.value.query == request.inputQuery + } + + private fun applyLoadMoreStoppedState() { + _state.update { currentState -> + currentState.copy( + isLoadingMore = false, + ) + } + } + + private fun applyLoadMoreResult(page: ConversationRecipientsPage) { + _state.update { currentState -> + currentState.copy( + contacts = mergeRecipients( + existingRecipients = currentState.contacts, + additionalRecipients = page.recipients, + ), + canLoadMore = page.nextOffset != null, + isLoadingMore = false, + ) + } + } + + private fun updateQueryInState(query: String) { + _state.update { currentState -> + currentState.copy( + query = query, + ) + } + } + + private suspend fun updateSearchSession( + transform: (RecipientSearchSession) -> RecipientSearchSession, + ) { + searchSessionMutex.withLock { + searchSession = transform(searchSession) + } + } + + private data class InitialSearchResult( + val effectiveQuery: String, + val page: ConversationRecipientsPage, + ) + + private data class LoadMoreRequest( + val effectiveQuery: String, + val inputQuery: String, + val excludedDestinations: Set, + val offset: Int, + ) + + private data class RecipientSearchSession( + val effectiveQuery: String, + val hasCompletedInitialLoad: Boolean, + val nextPageOffset: Int?, + ) + + private data class SearchInputs( + val query: String, + val excludedDestinations: Set, + ) + + private companion object { + private const val SEARCH_DEBOUNCE_MILLIS = 150L + private const val SEARCH_QUERY_KEY = "search_query" + } +} From 3d404f7fa288f590fe12d548f6aaa2d144a95e59 Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Fri, 17 Apr 2026 17:04:58 +0300 Subject: [PATCH 37/99] Add conversation navigation reducer --- .../v2/navigation/ConversationNavKey.kt | 5 + .../ConversationNavigationReducer.kt | 104 ++++++++++++++++++ 2 files changed, 109 insertions(+) create mode 100644 src/com/android/messaging/ui/conversation/v2/navigation/ConversationNavigationReducer.kt diff --git a/src/com/android/messaging/ui/conversation/v2/navigation/ConversationNavKey.kt b/src/com/android/messaging/ui/conversation/v2/navigation/ConversationNavKey.kt index 6f4bdac5..1b3531b5 100644 --- a/src/com/android/messaging/ui/conversation/v2/navigation/ConversationNavKey.kt +++ b/src/com/android/messaging/ui/conversation/v2/navigation/ConversationNavKey.kt @@ -16,6 +16,11 @@ internal data class RecipientPickerNavKey( val mode: RecipientPickerMode, ) : NavKey +@Serializable +internal data class AddParticipantsNavKey( + val conversationId: String, +) : NavKey + @Serializable internal enum class RecipientPickerMode { CREATE_GROUP, diff --git a/src/com/android/messaging/ui/conversation/v2/navigation/ConversationNavigationReducer.kt b/src/com/android/messaging/ui/conversation/v2/navigation/ConversationNavigationReducer.kt new file mode 100644 index 00000000..21c50024 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/v2/navigation/ConversationNavigationReducer.kt @@ -0,0 +1,104 @@ +package com.android.messaging.ui.conversation.v2.navigation + +import androidx.navigation3.runtime.NavKey + +internal interface ConversationNavigationReducer { + fun navigateToAddParticipants( + backStack: MutableList, + conversationId: String, + ) + + fun navigateToConversation( + backStack: MutableList, + conversationId: String, + ) + + fun navigateToRecipientPicker( + backStack: MutableList, + mode: RecipientPickerMode, + ) + + fun popBackStack(backStack: MutableList): Boolean + + fun replaceCurrentConversation( + backStack: MutableList, + conversationId: String, + ) + + fun resetBackStack( + backStack: MutableList, + destination: NavKey, + ) +} + +internal class ConversationNavigationReducerImpl : ConversationNavigationReducer { + + override fun navigateToAddParticipants( + backStack: MutableList, + conversationId: String, + ) { + AddParticipantsNavKey(conversationId = conversationId) + .takeIf { it != backStack.lastOrNull() } + ?.let(backStack::add) + } + + override fun navigateToConversation( + backStack: MutableList, + conversationId: String, + ) { + ConversationNavKey(conversationId = conversationId) + .takeIf { it != backStack.lastOrNull() } + ?.let(backStack::add) + } + + override fun navigateToRecipientPicker( + backStack: MutableList, + mode: RecipientPickerMode, + ) { + RecipientPickerNavKey(mode = mode) + .takeIf { it != backStack.lastOrNull() } + ?.let(backStack::add) + } + + override fun popBackStack(backStack: MutableList): Boolean { + if (backStack.size <= 1) { + return false + } + + backStack.removeAt(backStack.lastIndex) + return true + } + + override fun replaceCurrentConversation( + backStack: MutableList, + conversationId: String, + ) { + if (backStack.lastOrNull() is AddParticipantsNavKey) { + backStack.removeAt(backStack.lastIndex) + } + + val updatedConversation = ConversationNavKey(conversationId = conversationId) + val currentConversationIndex = backStack.indexOfLast { navKey -> + navKey is ConversationNavKey + } + + if (currentConversationIndex >= 0) { + backStack[currentConversationIndex] = updatedConversation + return + } + + backStack.add(updatedConversation) + } + + override fun resetBackStack( + backStack: MutableList, + destination: NavKey, + ) { + if (backStack.size == 1 && backStack.firstOrNull() == destination) { + return + } + + backStack.clear() + backStack.add(destination) + } +} From 0bfa12cf4651c22558b438c483c4b2f8a191afd9 Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Fri, 17 Apr 2026 17:05:50 +0300 Subject: [PATCH 38/99] Add add-participants conversation flow --- .../ConversationParticipantsRepository.kt | 114 +++++++ .../conversation/ConversationBindsModule.kt | 16 + .../CanAddMoreConversationParticipants.kt | 17 ++ .../conversation/v2/ConversationTestTags.kt | 8 + .../addparticipants/AddParticipantsScreen.kt | 158 ++++++++++ .../AddParticipantsViewModel.kt | 287 ++++++++++++++++++ .../model/AddParticipantsEffect.kt | 12 + .../model/AddParticipantsUiState.kt | 16 + .../v2/metadata/ui/ConversationTopAppBar.kt | 29 +- .../v2/navigation/ConversationNavGraph.kt | 78 +++-- .../v2/screen/ConversationScreen.kt | 5 + .../v2/screen/ConversationViewModel.kt | 19 ++ .../ConversationScreenScaffoldUiState.kt | 1 + 13 files changed, 731 insertions(+), 29 deletions(-) create mode 100644 src/com/android/messaging/data/conversation/repository/ConversationParticipantsRepository.kt create mode 100644 src/com/android/messaging/domain/conversation/usecase/CanAddMoreConversationParticipants.kt create mode 100644 src/com/android/messaging/ui/conversation/v2/addparticipants/AddParticipantsScreen.kt create mode 100644 src/com/android/messaging/ui/conversation/v2/addparticipants/AddParticipantsViewModel.kt create mode 100644 src/com/android/messaging/ui/conversation/v2/addparticipants/model/AddParticipantsEffect.kt create mode 100644 src/com/android/messaging/ui/conversation/v2/addparticipants/model/AddParticipantsUiState.kt diff --git a/src/com/android/messaging/data/conversation/repository/ConversationParticipantsRepository.kt b/src/com/android/messaging/data/conversation/repository/ConversationParticipantsRepository.kt new file mode 100644 index 00000000..401e93c5 --- /dev/null +++ b/src/com/android/messaging/data/conversation/repository/ConversationParticipantsRepository.kt @@ -0,0 +1,114 @@ +package com.android.messaging.data.conversation.repository + +import android.content.ContentResolver +import android.database.ContentObserver +import android.net.Uri +import com.android.messaging.data.conversation.model.recipient.ConversationRecipient +import com.android.messaging.datamodel.MessagingContentProvider +import com.android.messaging.datamodel.data.ParticipantData +import com.android.messaging.di.core.IoDispatcher +import javax.inject.Inject +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.conflate +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map + +internal interface ConversationParticipantsRepository { + fun getParticipants( + conversationId: String, + ): Flow> +} + +internal class ConversationParticipantsRepositoryImpl @Inject constructor( + private val contentResolver: ContentResolver, + @param:IoDispatcher + private val ioDispatcher: CoroutineDispatcher, +) : ConversationParticipantsRepository { + + override fun getParticipants( + conversationId: String, + ): Flow> { + val uri = MessagingContentProvider.buildConversationParticipantsUri(conversationId) + + return observeUri(uri = uri) + .conflate() + .map { + queryParticipants(uri = uri) + } + .flowOn(ioDispatcher) + } + + private fun observeUri(uri: Uri): Flow { + return callbackFlow { + val observer = object : ContentObserver(null) { + override fun onChange(selfChange: Boolean) { + trySend(Unit) + } + } + contentResolver.registerContentObserver(uri, true, observer) + + trySend(Unit) + + awaitClose { + contentResolver.unregisterContentObserver(observer) + } + } + } + + private fun queryParticipants( + uri: Uri, + ): ImmutableList { + return contentResolver + .query( + uri, + ParticipantData.ParticipantsQuery.PROJECTION, + null, + null, + null, + ) + ?.use { cursor -> + val participants = persistentListOf().builder() + val seenDestinations = LinkedHashSet() + + while (cursor.moveToNext()) { + val participant = ParticipantData.getFromCursor(cursor) + + if (participant.isSelf) { + continue + } + + val destination = participant.sendDestination + ?.trim() + .orEmpty() + + if (destination.isBlank()) { + continue + } + + if (!seenDestinations.add(destination)) { + continue + } + + participants.add( + ConversationRecipient( + id = participant.id, + displayName = participant.getDisplayName(true), + destination = destination, + photoUri = participant.profilePhotoUri, + secondaryText = participant.displayDestination + ?.takeIf { it.isNotBlank() } + ?.takeIf { it != participant.getDisplayName(true) }, + ), + ) + } + + participants.build() + } + ?: persistentListOf() + } +} diff --git a/src/com/android/messaging/di/conversation/ConversationBindsModule.kt b/src/com/android/messaging/di/conversation/ConversationBindsModule.kt index 71c4d2dd..f30b3768 100644 --- a/src/com/android/messaging/di/conversation/ConversationBindsModule.kt +++ b/src/com/android/messaging/di/conversation/ConversationBindsModule.kt @@ -10,6 +10,8 @@ import com.android.messaging.data.conversation.repository.ConversationDraftsRepo import com.android.messaging.data.conversation.repository.ConversationDraftsRepositoryImpl import com.android.messaging.data.conversation.repository.ConversationMetadataNotifier import com.android.messaging.data.conversation.repository.ConversationMetadataNotifierImpl +import com.android.messaging.data.conversation.repository.ConversationParticipantsRepository +import com.android.messaging.data.conversation.repository.ConversationParticipantsRepositoryImpl import com.android.messaging.data.conversation.repository.ConversationRecipientsRepository import com.android.messaging.data.conversation.repository.ConversationRecipientsRepositoryImpl import com.android.messaging.data.conversation.repository.ConversationsRepository @@ -18,6 +20,8 @@ import com.android.messaging.data.media.repository.ConversationMediaRepository import com.android.messaging.data.media.repository.ConversationMediaRepositoryImpl import com.android.messaging.domain.contacts.usecase.IsReadContactsPermissionGranted import com.android.messaging.domain.contacts.usecase.IsReadContactsPermissionGrantedImpl +import com.android.messaging.domain.conversation.usecase.CanAddMoreConversationParticipants +import com.android.messaging.domain.conversation.usecase.CanAddMoreConversationParticipantsImpl import com.android.messaging.domain.conversation.usecase.IsConversationRecipientLimitExceeded import com.android.messaging.domain.conversation.usecase.IsConversationRecipientLimitExceededImpl import com.android.messaging.domain.conversation.usecase.ResolveConversationId @@ -72,12 +76,24 @@ internal abstract class ConversationBindsModule { impl: ConversationDraftsRepositoryImpl, ): ConversationDraftsRepository + @Binds + @Reusable + abstract fun bindConversationParticipantsRepository( + impl: ConversationParticipantsRepositoryImpl, + ): ConversationParticipantsRepository + @Binds @Reusable abstract fun bindConversationRecipientsRepository( impl: ConversationRecipientsRepositoryImpl, ): ConversationRecipientsRepository + @Binds + @Reusable + abstract fun bindCanAddMoreConversationParticipants( + impl: CanAddMoreConversationParticipantsImpl, + ): CanAddMoreConversationParticipants + @Binds @Reusable abstract fun bindIsReadContactsPermissionGranted( diff --git a/src/com/android/messaging/domain/conversation/usecase/CanAddMoreConversationParticipants.kt b/src/com/android/messaging/domain/conversation/usecase/CanAddMoreConversationParticipants.kt new file mode 100644 index 00000000..8e0b4f87 --- /dev/null +++ b/src/com/android/messaging/domain/conversation/usecase/CanAddMoreConversationParticipants.kt @@ -0,0 +1,17 @@ +package com.android.messaging.domain.conversation.usecase + +import com.android.messaging.datamodel.data.ContactPickerData +import javax.inject.Inject + +internal fun interface CanAddMoreConversationParticipants { + operator fun invoke(participantCount: Int): Boolean +} + +// TODO: Get rid of legacy ContactPickerData usage +internal class CanAddMoreConversationParticipantsImpl @Inject constructor() : + CanAddMoreConversationParticipants { + + override operator fun invoke(participantCount: Int): Boolean { + return ContactPickerData.getCanAddMoreParticipants(participantCount) + } +} diff --git a/src/com/android/messaging/ui/conversation/v2/ConversationTestTags.kt b/src/com/android/messaging/ui/conversation/v2/ConversationTestTags.kt index 9d61891a..f7f18f09 100644 --- a/src/com/android/messaging/ui/conversation/v2/ConversationTestTags.kt +++ b/src/com/android/messaging/ui/conversation/v2/ConversationTestTags.kt @@ -7,9 +7,13 @@ internal const val CONVERSATION_COMPOSE_BAR_TEST_TAG = "conversation_compose_bar internal const val CONVERSATION_ATTACHMENT_BUTTON_TEST_TAG = "conversation_attachment_button" internal const val CONVERSATION_ATTACHMENT_PREVIEW_LIST_TEST_TAG = "conversation_attachment_preview_list" +internal const val CONVERSATION_ADD_PEOPLE_BUTTON_TEST_TAG = + "conversation_add_people_button" internal const val CONVERSATION_LOADING_INDICATOR_TEST_TAG = "conversation_loading_indicator" internal const val CONVERSATION_MESSAGES_LIST_TEST_TAG = "conversation_messages_list" internal const val CONVERSATION_MEDIA_PICKER_OVERLAY_TEST_TAG = "conversation_media_picker_overlay" +internal const val ADD_PARTICIPANTS_CONFIRM_BUTTON_TEST_TAG = + "add_participants_confirm_button" internal const val NEW_CHAT_CREATE_GROUP_NEXT_BUTTON_TEST_TAG = "new_chat_create_group_next_button" internal const val NEW_CHAT_CONTACT_RESOLVING_INDICATOR_TEST_TAG = @@ -36,6 +40,10 @@ internal fun newChatContactRowTestTag(contactId: String): String { return "new_chat_contact_row_$contactId" } +internal fun addParticipantsContactRowTestTag(contactId: String): String { + return "add_participants_contact_row_$contactId" +} + internal val conversationShapeSemanticsKey = SemanticsPropertyKey( name = "conversation_shape", ) diff --git a/src/com/android/messaging/ui/conversation/v2/addparticipants/AddParticipantsScreen.kt b/src/com/android/messaging/ui/conversation/v2/addparticipants/AddParticipantsScreen.kt new file mode 100644 index 00000000..2f25929c --- /dev/null +++ b/src/com/android/messaging/ui/conversation/v2/addparticipants/AddParticipantsScreen.kt @@ -0,0 +1,158 @@ +@file:OptIn( + ExperimentalMaterial3Api::class, +) + +package com.android.messaging.ui.conversation.v2.addparticipants + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.ArrowBack +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.android.messaging.R +import com.android.messaging.ui.conversation.v2.ADD_PARTICIPANTS_CONFIRM_BUTTON_TEST_TAG +import com.android.messaging.ui.conversation.v2.addParticipantsContactRowTestTag +import com.android.messaging.ui.conversation.v2.addparticipants.model.AddParticipantsEffect +import com.android.messaging.ui.conversation.v2.addparticipants.model.AddParticipantsUiState +import com.android.messaging.ui.conversation.v2.recipientpicker.RecipientSelectionContent +import com.android.messaging.ui.conversation.v2.recipientpicker.RecipientSelectionContentUiState +import com.android.messaging.ui.conversation.v2.recipientpicker.RecipientSelectionPrimaryActionUiState +import com.android.messaging.ui.conversation.v2.recipientpicker.RecipientSelectionRowDecorators +import com.android.messaging.ui.conversation.v2.recipientpicker.RecipientSelectionStrings +import com.android.messaging.util.UiUtils +import kotlinx.collections.immutable.toImmutableSet + +@Composable +internal fun AddParticipantsScreen( + conversationId: String, + modifier: Modifier = Modifier, + onNavigateBack: () -> Unit = {}, + onNavigateToConversation: (String) -> Unit = {}, + screenModel: AddParticipantsModel = hiltViewModel(), +) { + val uiState by screenModel.uiState.collectAsStateWithLifecycle() + + LaunchedEffect(conversationId, screenModel) { + screenModel.onConversationIdChanged(conversationId = conversationId) + } + + LaunchedEffect(screenModel, onNavigateToConversation) { + screenModel.effects.collect { effect -> + when (effect) { + is AddParticipantsEffect.NavigateToConversation -> { + onNavigateToConversation(effect.conversationId) + } + + is AddParticipantsEffect.ShowMessage -> { + UiUtils.showToastAtBottom(effect.messageResId) + } + } + } + } + + Scaffold( + modifier = modifier, + containerColor = MaterialTheme.colorScheme.surfaceVariant, + topBar = { + TopAppBar( + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant, + navigationIconContentColor = MaterialTheme.colorScheme.onSurface, + titleContentColor = MaterialTheme.colorScheme.onSurface, + ), + navigationIcon = { + IconButton( + onClick = onNavigateBack, + ) { + Icon( + imageVector = Icons.AutoMirrored.Rounded.ArrowBack, + contentDescription = stringResource(id = R.string.back), + ) + } + }, + title = { + Text(text = stringResource(id = R.string.conversation_add_people)) + }, + ) + }, + ) { contentPadding -> + AddParticipantsRecipientSelectionContent( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues = contentPadding), + uiState = uiState, + onLoadMore = screenModel::onLoadMore, + onQueryChanged = screenModel::onQueryChanged, + onConfirmClick = screenModel::onConfirmClick, + onRecipientClick = screenModel::onRecipientClicked, + ) + } +} + +@Composable +private fun AddParticipantsRecipientSelectionContent( + uiState: AddParticipantsUiState, + onConfirmClick: () -> Unit, + onLoadMore: () -> Unit, + onQueryChanged: (String) -> Unit, + onRecipientClick: (String) -> Unit, + modifier: Modifier = Modifier, +) { + val primaryAction = when { + uiState.selectedRecipientDestinations.isNotEmpty() -> { + RecipientSelectionPrimaryActionUiState( + text = stringResource(id = R.string.conversation_add_people), + isEnabled = !uiState.isLoadingConversationParticipants && + !uiState.recipientPickerUiState.isLoading && + !uiState.isResolvingConversation, + isLoading = uiState.isResolvingConversation, + testTag = ADD_PARTICIPANTS_CONFIRM_BUTTON_TEST_TAG, + ) + } + + else -> null + } + + RecipientSelectionContent( + uiState = RecipientSelectionContentUiState( + picker = uiState.recipientPickerUiState.copy( + isLoading = uiState.isLoadingConversationParticipants || + uiState.recipientPickerUiState.isLoading, + ), + primaryAction = primaryAction, + selectedRecipientDestinations = uiState.selectedRecipientDestinations.toImmutableSet(), + isQueryEnabled = !uiState.isResolvingConversation && + !uiState.isLoadingConversationParticipants, + ), + strings = RecipientSelectionStrings( + queryPrefixText = stringResource(id = R.string.to_address_label), + queryPlaceholderText = stringResource(id = R.string.new_chat_query_hint), + ), + rowDecorators = RecipientSelectionRowDecorators( + recipientRowTestTag = { contact -> + addParticipantsContactRowTestTag(contactId = contact.id) + }, + ), + onRecipientClick = { contact -> + onRecipientClick(contact.destination) + }, + modifier = modifier, + onLoadMore = onLoadMore, + onPrimaryActionClick = onConfirmClick, + onQueryChanged = onQueryChanged, + ) +} diff --git a/src/com/android/messaging/ui/conversation/v2/addparticipants/AddParticipantsViewModel.kt b/src/com/android/messaging/ui/conversation/v2/addparticipants/AddParticipantsViewModel.kt new file mode 100644 index 00000000..fabdcba3 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/v2/addparticipants/AddParticipantsViewModel.kt @@ -0,0 +1,287 @@ +package com.android.messaging.ui.conversation.v2.addparticipants + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.android.messaging.R +import com.android.messaging.data.conversation.model.recipient.ConversationRecipient +import com.android.messaging.data.conversation.repository.ConversationParticipantsRepository +import com.android.messaging.di.core.MainDispatcher +import com.android.messaging.domain.conversation.usecase.IsConversationRecipientLimitExceeded +import com.android.messaging.domain.conversation.usecase.ResolveConversationId +import com.android.messaging.domain.conversation.usecase.model.ResolveConversationIdResult +import com.android.messaging.ui.conversation.v2.addparticipants.model.AddParticipantsEffect +import com.android.messaging.ui.conversation.v2.addparticipants.model.AddParticipantsUiState +import com.android.messaging.ui.conversation.v2.recipientpicker.delegate.RecipientPickerDelegate +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.PersistentList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch + +internal interface AddParticipantsModel { + val effects: Flow + val uiState: StateFlow + + fun onConversationIdChanged(conversationId: String?) + fun onLoadMore() + fun onQueryChanged(query: String) + fun onRecipientClicked(destination: String) + fun onConfirmClick() +} + +@HiltViewModel +internal class AddParticipantsViewModel @Inject constructor( + private val conversationParticipantsRepository: ConversationParticipantsRepository, + private val isConversationRecipientLimitExceeded: IsConversationRecipientLimitExceeded, + private val recipientPickerDelegate: RecipientPickerDelegate, + private val resolveConversationId: ResolveConversationId, + private val savedStateHandle: SavedStateHandle, + @param:MainDispatcher + private val mainDispatcher: CoroutineDispatcher, +) : ViewModel(), + AddParticipantsModel { + + private val conversationIdFlow: StateFlow = savedStateHandle.getStateFlow( + key = CONVERSATION_ID_KEY, + initialValue = null, + ) + private val _effects = MutableSharedFlow( + extraBufferCapacity = 1, + ) + private val localUiState = MutableStateFlow( + value = LocalAddParticipantsUiState(), + ) + + override val effects = _effects.asSharedFlow() + + override val uiState: StateFlow = combine( + localUiState, + recipientPickerDelegate.state, + ) { localState, recipientPickerUiState -> + AddParticipantsUiState( + existingParticipants = localState.existingParticipants, + isLoadingConversationParticipants = localState.isLoadingConversationParticipants, + isResolvingConversation = localState.isResolvingConversation, + recipientPickerUiState = recipientPickerUiState, + selectedRecipientDestinations = localState.selectedRecipientDestinations, + ) + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed( + stopTimeoutMillis = STATEFLOW_STOP_TIMEOUT_MILLIS, + ), + initialValue = AddParticipantsUiState( + existingParticipants = localUiState.value.existingParticipants, + isLoadingConversationParticipants = localUiState + .value + .isLoadingConversationParticipants, + isResolvingConversation = localUiState.value.isResolvingConversation, + recipientPickerUiState = recipientPickerDelegate.state.value, + selectedRecipientDestinations = localUiState.value.selectedRecipientDestinations, + ), + ) + + init { + recipientPickerDelegate.bind(scope = viewModelScope) + bindConversationParticipants() + } + + private fun bindConversationParticipants() { + viewModelScope.launch(mainDispatcher) { + conversationIdFlow.collectLatest { conversationId -> + updateLocalUiState( + localUiState.value.copy( + existingParticipants = persistentEmptyParticipants(), + isLoadingConversationParticipants = conversationId != null, + isResolvingConversation = false, + selectedRecipientDestinations = persistentEmptyDestinations(), + ), + ) + recipientPickerDelegate.onExcludedDestinationsChanged( + destinations = emptySet(), + ) + + if (conversationId == null) { + return@collectLatest + } + + conversationParticipantsRepository + .getParticipants(conversationId = conversationId) + .collect { participants -> + val selectedDestinations = localUiState.value + .selectedRecipientDestinations + .filterNot { selectedDestination -> + participants.any { participant -> + participant.destination == selectedDestination + } + } + .toImmutableList() + + updateLocalUiState( + localUiState.value.copy( + existingParticipants = participants, + isLoadingConversationParticipants = false, + selectedRecipientDestinations = selectedDestinations, + ), + ) + recipientPickerDelegate.onExcludedDestinationsChanged( + destinations = participants + .map { participant -> + participant.destination + } + .toSet(), + ) + } + } + } + } + + override fun onConversationIdChanged(conversationId: String?) { + if (conversationId != conversationIdFlow.value) { + savedStateHandle[CONVERSATION_ID_KEY] = conversationId + } + } + + override fun onLoadMore() { + recipientPickerDelegate.onLoadMore() + } + + override fun onQueryChanged(query: String) { + recipientPickerDelegate.onQueryChanged(query = query) + } + + override fun onRecipientClicked(destination: String) { + val trimmedDestination = destination.trim() + val currentUiState = localUiState.value + + val shouldIgnoreRecipientClick = trimmedDestination.isEmpty() || + currentUiState.isLoadingConversationParticipants || + currentUiState.isResolvingConversation || + currentUiState.existingParticipants.any { participant -> + participant.destination == trimmedDestination + } + + if (shouldIgnoreRecipientClick) { + return + } + + val nextSelectedDestinations = when { + trimmedDestination in currentUiState.selectedRecipientDestinations -> { + currentUiState.selectedRecipientDestinations - trimmedDestination + } + + else -> { + currentUiState.selectedRecipientDestinations + trimmedDestination + } + } + + updateLocalUiState( + currentUiState.copy( + selectedRecipientDestinations = nextSelectedDestinations.toImmutableList(), + ), + ) + } + + override fun onConfirmClick() { + val currentUiState = localUiState.value + + val shouldIgnoreConfirmClick = currentUiState.isLoadingConversationParticipants || + currentUiState.isResolvingConversation || + currentUiState.selectedRecipientDestinations.isEmpty() + + if (shouldIgnoreConfirmClick) { + return + } + + val allDestinations = ( + currentUiState.existingParticipants.map { participant -> + participant.destination + } + currentUiState.selectedRecipientDestinations + ).distinct() + + if (isConversationRecipientLimitExceeded(participantCount = allDestinations.size)) { + showMessage(messageResId = R.string.too_many_participants) + return + } + + viewModelScope.launch(mainDispatcher) { + updateLocalUiState( + currentUiState.copy( + isResolvingConversation = true, + ), + ) + + when (val result = resolveConversationId(destinations = allDestinations)) { + is ResolveConversationIdResult.Resolved -> { + updateLocalUiState( + localUiState.value.copy( + isResolvingConversation = false, + selectedRecipientDestinations = persistentEmptyDestinations(), + ), + ) + _effects.tryEmit( + AddParticipantsEffect.NavigateToConversation( + conversationId = result.conversationId, + ), + ) + } + + ResolveConversationIdResult.EmptyDestinations, + ResolveConversationIdResult.NotResolved, + -> { + updateLocalUiState( + localUiState.value.copy( + isResolvingConversation = false, + ), + ) + showMessage(messageResId = R.string.conversation_creation_failure) + } + } + } + } + + private fun showMessage(messageResId: Int) { + _effects.tryEmit( + AddParticipantsEffect.ShowMessage( + messageResId = messageResId, + ), + ) + } + + private fun updateLocalUiState(uiState: LocalAddParticipantsUiState) { + localUiState.value = uiState + } + + private fun persistentEmptyParticipants(): PersistentList { + return persistentListOf() + } + + private fun persistentEmptyDestinations(): PersistentList { + return persistentListOf() + } + + private data class LocalAddParticipantsUiState( + val existingParticipants: ImmutableList = persistentListOf(), + val isLoadingConversationParticipants: Boolean = true, + val isResolvingConversation: Boolean = false, + val selectedRecipientDestinations: ImmutableList = persistentListOf(), + ) + + private companion object { + private const val CONVERSATION_ID_KEY = "conversation_id" + private const val STATEFLOW_STOP_TIMEOUT_MILLIS = 5_000L + } +} diff --git a/src/com/android/messaging/ui/conversation/v2/addparticipants/model/AddParticipantsEffect.kt b/src/com/android/messaging/ui/conversation/v2/addparticipants/model/AddParticipantsEffect.kt new file mode 100644 index 00000000..c32213a7 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/v2/addparticipants/model/AddParticipantsEffect.kt @@ -0,0 +1,12 @@ +package com.android.messaging.ui.conversation.v2.addparticipants.model + +internal sealed interface AddParticipantsEffect { + + data class NavigateToConversation( + val conversationId: String, + ) : AddParticipantsEffect + + data class ShowMessage( + val messageResId: Int, + ) : AddParticipantsEffect +} diff --git a/src/com/android/messaging/ui/conversation/v2/addparticipants/model/AddParticipantsUiState.kt b/src/com/android/messaging/ui/conversation/v2/addparticipants/model/AddParticipantsUiState.kt new file mode 100644 index 00000000..b4c9966c --- /dev/null +++ b/src/com/android/messaging/ui/conversation/v2/addparticipants/model/AddParticipantsUiState.kt @@ -0,0 +1,16 @@ +package com.android.messaging.ui.conversation.v2.addparticipants.model + +import androidx.compose.runtime.Immutable +import com.android.messaging.data.conversation.model.recipient.ConversationRecipient +import com.android.messaging.ui.conversation.v2.recipientpicker.model.RecipientPickerUiState +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf + +@Immutable +internal data class AddParticipantsUiState( + val existingParticipants: ImmutableList = persistentListOf(), + val isLoadingConversationParticipants: Boolean = true, + val isResolvingConversation: Boolean = false, + val recipientPickerUiState: RecipientPickerUiState = RecipientPickerUiState(), + val selectedRecipientDestinations: ImmutableList = persistentListOf(), +) diff --git a/src/com/android/messaging/ui/conversation/v2/metadata/ui/ConversationTopAppBar.kt b/src/com/android/messaging/ui/conversation/v2/metadata/ui/ConversationTopAppBar.kt index d261c025..e518e856 100644 --- a/src/com/android/messaging/ui/conversation/v2/metadata/ui/ConversationTopAppBar.kt +++ b/src/com/android/messaging/ui/conversation/v2/metadata/ui/ConversationTopAppBar.kt @@ -11,6 +11,7 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.ArrowBack import androidx.compose.material.icons.rounded.Group +import androidx.compose.material.icons.rounded.GroupAdd import androidx.compose.material.icons.rounded.Person import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon @@ -26,12 +27,14 @@ import androidx.compose.runtime.Immutable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.android.messaging.R +import com.android.messaging.ui.conversation.v2.CONVERSATION_ADD_PEOPLE_BUTTON_TEST_TAG import com.android.messaging.ui.conversation.v2.metadata.model.ConversationMetadataUiState private val CONVERSATION_TOP_APP_BAR_TITLE_SPACING = 12.dp @@ -43,6 +46,8 @@ private val CONVERSATION_TOP_APP_BAR_AVATAR_ICON_SIZE = 20.dp internal fun ConversationTopAppBar( modifier: Modifier = Modifier, metadata: ConversationMetadataUiState, + isAddPeopleVisible: Boolean = false, + onAddPeopleClick: () -> Unit, onNavigateBack: () -> Unit, ) { val presentation = rememberConversationTopAppBarPresentation( @@ -62,6 +67,13 @@ internal fun ConversationTopAppBar( onNavigateBack = onNavigateBack, ) }, + actions = { + if (isAddPeopleVisible) { + ConversationTopAppBarAddPeopleAction( + onAddPeopleClick = onAddPeopleClick, + ) + } + }, ) } @@ -109,7 +121,7 @@ private fun ConversationTopAppBarTitle( ) { Row( horizontalArrangement = Arrangement.spacedBy( - space = CONVERSATION_TOP_APP_BAR_TITLE_SPACING + space = CONVERSATION_TOP_APP_BAR_TITLE_SPACING, ), verticalAlignment = Alignment.CenterVertically, ) { @@ -165,6 +177,21 @@ private fun ConversationTopAppBarNavigationIcon( } } +@Composable +private fun ConversationTopAppBarAddPeopleAction( + onAddPeopleClick: () -> Unit, +) { + IconButton( + modifier = Modifier.testTag(CONVERSATION_ADD_PEOPLE_BUTTON_TEST_TAG), + onClick = onAddPeopleClick, + ) { + Icon( + imageVector = Icons.Rounded.GroupAdd, + contentDescription = stringResource(id = R.string.conversation_add_people), + ) + } +} + @Composable private fun ConversationAvatar( isGroupConversation: Boolean, diff --git a/src/com/android/messaging/ui/conversation/v2/navigation/ConversationNavGraph.kt b/src/com/android/messaging/ui/conversation/v2/navigation/ConversationNavGraph.kt index 89041fec..7e3f1473 100644 --- a/src/com/android/messaging/ui/conversation/v2/navigation/ConversationNavGraph.kt +++ b/src/com/android/messaging/ui/conversation/v2/navigation/ConversationNavGraph.kt @@ -15,6 +15,7 @@ import androidx.navigation3.runtime.rememberNavBackStack import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator import androidx.navigation3.ui.NavDisplay import com.android.messaging.data.conversation.model.draft.ConversationDraft +import com.android.messaging.ui.conversation.v2.addparticipants.AddParticipantsScreen import com.android.messaging.ui.conversation.v2.entry.ConversationEntryModel import com.android.messaging.ui.conversation.v2.entry.ConversationEntryViewModel import com.android.messaging.ui.conversation.v2.entry.NewChatScreen @@ -32,11 +33,13 @@ internal fun ConversationNavGraph( modifier: Modifier = Modifier, onFinish: () -> Unit, entryModel: ConversationEntryModel = hiltViewModel(), + navigationReducer: ConversationNavigationReducer = DEFAULT_CONVERSATION_NAVIGATION_REDUCER, ) { val entryUiState by entryModel.uiState.collectAsStateWithLifecycle() val backStack = rememberNavBackStack(initialNavKey(launchRequest = launchRequest)) val latestEntryModel = rememberUpdatedState(newValue = entryModel) val latestEntryUiState = rememberUpdatedState(newValue = entryUiState) + val latestNavigationReducer = rememberUpdatedState(newValue = navigationReducer) val latestOnFinish = rememberUpdatedState(newValue = onFinish) val entryDecorators = listOf( @@ -56,9 +59,16 @@ internal fun ConversationNavGraph( ConversationScreen( conversationId = navKey.conversationId, launchGeneration = currentEntryUiState.launchGeneration, + onAddPeopleClick = { + latestNavigationReducer.value.navigateToAddParticipants( + backStack = backStack, + conversationId = navKey.conversationId, + ) + }, onNavigateBack = { popBackStackOrFinish( backStack = backStack, + navigationReducer = latestNavigationReducer.value, onFinish = currentOnFinish, ) }, @@ -102,6 +112,7 @@ internal fun ConversationNavGraph( entryModel = currentEntryModel, entryUiState = currentEntryUiState, backStack = backStack, + navigationReducer = latestNavigationReducer.value, onFinish = latestOnFinish.value, ) }, @@ -112,6 +123,25 @@ internal fun ConversationNavGraph( ) } + entry { navKey -> + AddParticipantsScreen( + conversationId = navKey.conversationId, + onNavigateBack = { + popBackStackOrFinish( + backStack = backStack, + navigationReducer = latestNavigationReducer.value, + onFinish = latestOnFinish.value, + ) + }, + onNavigateToConversation = { resolvedConversationId -> + latestNavigationReducer.value.replaceCurrentConversation( + backStack = backStack, + conversationId = resolvedConversationId, + ) + }, + ) + } + entry { navKey -> RecipientPickerScreen(mode = navKey.mode) } @@ -123,6 +153,7 @@ internal fun ConversationNavGraph( updateBackStackForLaunch( backStack = backStack, launchRequest = launchRequest, + navigationReducer = latestNavigationReducer.value, ) } @@ -131,6 +162,7 @@ internal fun ConversationNavGraph( handleEntryEffect( backStack = backStack, effect = effect, + navigationReducer = latestNavigationReducer.value, onFinish = onFinish, ) } @@ -144,6 +176,7 @@ internal fun ConversationNavGraph( backStack = backStack, entryModel = latestEntryModel.value, entryUiState = latestEntryUiState.value, + navigationReducer = latestNavigationReducer.value, onFinish = latestOnFinish.value, ) }, @@ -174,23 +207,21 @@ private fun pendingDraftForConversation( private fun updateBackStackForLaunch( backStack: MutableList, launchRequest: ConversationEntryLaunchRequest?, + navigationReducer: ConversationNavigationReducer, ) { val destination = initialNavKey(launchRequest = launchRequest) - - if (backStack.size == 1 && backStack.firstOrNull() == destination) { - return - } - - backStack.clear() - backStack.add(destination) + navigationReducer.resetBackStack( + backStack = backStack, + destination = destination, + ) } private fun popBackStackOrFinish( backStack: MutableList, + navigationReducer: ConversationNavigationReducer, onFinish: () -> Unit, ) { - if (backStack.size > 1) { - backStack.removeAt(backStack.lastIndex) + if (navigationReducer.popBackStack(backStack = backStack)) { return } @@ -201,6 +232,7 @@ private fun handleNavBack( backStack: MutableList, entryModel: ConversationEntryModel, entryUiState: ConversationEntryUiState, + navigationReducer: ConversationNavigationReducer, onFinish: () -> Unit, ) { if (backStack.lastOrNull() == NewChatNavKey && entryUiState.isCreatingGroup) { @@ -210,6 +242,7 @@ private fun handleNavBack( popBackStackOrFinish( backStack = backStack, + navigationReducer = navigationReducer, onFinish = onFinish, ) } @@ -218,6 +251,7 @@ private fun handleNewChatBack( entryModel: ConversationEntryModel, entryUiState: ConversationEntryUiState, backStack: MutableList, + navigationReducer: ConversationNavigationReducer, onFinish: () -> Unit, ) { if (entryUiState.isCreatingGroup) { @@ -227,6 +261,7 @@ private fun handleNewChatBack( popBackStackOrFinish( backStack = backStack, + navigationReducer = navigationReducer, onFinish = onFinish, ) } @@ -246,25 +281,27 @@ private fun pendingStartupAttachmentForConversation( private fun handleEntryEffect( backStack: MutableList, effect: ConversationEntryEffect, + navigationReducer: ConversationNavigationReducer, onFinish: () -> Unit, ) { when (effect) { is ConversationEntryEffect.NavigateBack -> { popBackStackOrFinish( backStack = backStack, + navigationReducer = navigationReducer, onFinish = onFinish, ) } is ConversationEntryEffect.NavigateToConversation -> { - navigateToConversation( + navigationReducer.navigateToConversation( backStack = backStack, conversationId = effect.conversationId, ) } is ConversationEntryEffect.NavigateToRecipientPicker -> { - navigateToRecipientPicker( + navigationReducer.navigateToRecipientPicker( backStack = backStack, mode = effect.mode, ) @@ -276,20 +313,5 @@ private fun handleEntryEffect( } } -private fun navigateToConversation( - backStack: MutableList, - conversationId: String, -) { - ConversationNavKey(conversationId = conversationId) - .takeIf { it != backStack.lastOrNull() } - ?.let(backStack::add) -} - -private fun navigateToRecipientPicker( - backStack: MutableList, - mode: RecipientPickerMode, -) { - RecipientPickerNavKey(mode = mode) - .takeIf { it != backStack.lastOrNull() } - ?.let(backStack::add) -} +private val DEFAULT_CONVERSATION_NAVIGATION_REDUCER: ConversationNavigationReducer = + ConversationNavigationReducerImpl() diff --git a/src/com/android/messaging/ui/conversation/v2/screen/ConversationScreen.kt b/src/com/android/messaging/ui/conversation/v2/screen/ConversationScreen.kt index a1e21e08..594955c0 100644 --- a/src/com/android/messaging/ui/conversation/v2/screen/ConversationScreen.kt +++ b/src/com/android/messaging/ui/conversation/v2/screen/ConversationScreen.kt @@ -45,6 +45,7 @@ internal fun ConversationScreen( modifier: Modifier = Modifier, conversationId: String? = null, launchGeneration: Int? = null, + onAddPeopleClick: () -> Unit = {}, onNavigateBack: () -> Unit = {}, pendingDraft: ConversationDraft? = null, pendingStartupAttachment: ConversationEntryStartupAttachment? = null, @@ -128,6 +129,7 @@ internal fun ConversationScreen( uiState = scaffoldUiState, isMediaPickerOpen = mediaPickerState.isOpen, messageFieldFocusRequester = messageFieldFocusRequester, + onAddPeopleClick = onAddPeopleClick, onNavigateBack = onNavigateBack, onOpenMediaPicker = mediaPickerState::open, onMessageTextChange = screenModel::onMessageTextChanged, @@ -166,6 +168,7 @@ private fun ConversationScreenScaffold( uiState: ConversationScreenScaffoldUiState, isMediaPickerOpen: Boolean, messageFieldFocusRequester: FocusRequester, + onAddPeopleClick: () -> Unit, onNavigateBack: () -> Unit, onOpenMediaPicker: () -> Unit, onMessageTextChange: (String) -> Unit, @@ -181,6 +184,8 @@ private fun ConversationScreenScaffold( topBar = { ConversationTopAppBar( metadata = uiState.metadata, + isAddPeopleVisible = uiState.canAddPeople, + onAddPeopleClick = onAddPeopleClick, onNavigateBack = onNavigateBack, ) }, diff --git a/src/com/android/messaging/ui/conversation/v2/screen/ConversationViewModel.kt b/src/com/android/messaging/ui/conversation/v2/screen/ConversationViewModel.kt index fd885e62..5f17266a 100644 --- a/src/com/android/messaging/ui/conversation/v2/screen/ConversationViewModel.kt +++ b/src/com/android/messaging/ui/conversation/v2/screen/ConversationViewModel.kt @@ -7,6 +7,7 @@ import com.android.messaging.data.conversation.model.draft.ConversationDraft import com.android.messaging.data.media.model.ConversationMediaItem import com.android.messaging.datamodel.MessagingContentProvider import com.android.messaging.di.core.DefaultDispatcher +import com.android.messaging.domain.conversation.usecase.CanAddMoreConversationParticipants import com.android.messaging.ui.conversation.v2.composer.delegate.ConversationDraftDelegate import com.android.messaging.ui.conversation.v2.composer.mapper.ConversationComposerUiStateMapper import com.android.messaging.ui.conversation.v2.composer.model.ConversationComposerAttachmentUiState @@ -80,6 +81,7 @@ internal class ConversationViewModel @Inject constructor( private val conversationMediaPickerDelegate: ConversationMediaPickerDelegate, private val conversationMetadataDelegate: ConversationMetadataDelegate, private val conversationComposerUiStateMapper: ConversationComposerUiStateMapper, + private val canAddMoreConversationParticipants: CanAddMoreConversationParticipants, @param:DefaultDispatcher private val defaultDispatcher: CoroutineDispatcher, private val savedStateHandle: SavedStateHandle, @@ -121,6 +123,7 @@ internal class ConversationViewModel @Inject constructor( composerUiState, ) { metadataState, messagesUiState, composerUiState -> ConversationScreenScaffoldUiState( + canAddPeople = canAddPeople(metadataState = metadataState), metadata = metadataState, messages = messagesUiState, composer = composerUiState, @@ -131,6 +134,9 @@ internal class ConversationViewModel @Inject constructor( stopTimeoutMillis = STATEFLOW_STOP_TIMEOUT_MILLIS, ), initialValue = ConversationScreenScaffoldUiState( + canAddPeople = canAddPeople( + metadataState = conversationMetadataDelegate.state.value, + ), metadata = conversationMetadataDelegate.state.value, messages = conversationMessagesDelegate.state.value, composer = composerUiState.value, @@ -207,6 +213,19 @@ internal class ConversationViewModel @Inject constructor( } } + private fun canAddPeople( + metadataState: ConversationMetadataUiState, + ): Boolean { + return when (metadataState) { + is ConversationMetadataUiState.Present -> { + canAddMoreConversationParticipants( + participantCount = metadataState.participantCount, + ) + } + else -> false + } + } + override fun onSeedDraft( conversationId: String, draft: ConversationDraft, diff --git a/src/com/android/messaging/ui/conversation/v2/screen/model/ConversationScreenScaffoldUiState.kt b/src/com/android/messaging/ui/conversation/v2/screen/model/ConversationScreenScaffoldUiState.kt index 28dafb81..796c5835 100644 --- a/src/com/android/messaging/ui/conversation/v2/screen/model/ConversationScreenScaffoldUiState.kt +++ b/src/com/android/messaging/ui/conversation/v2/screen/model/ConversationScreenScaffoldUiState.kt @@ -7,6 +7,7 @@ import com.android.messaging.ui.conversation.v2.metadata.model.ConversationMetad @Immutable internal data class ConversationScreenScaffoldUiState( + val canAddPeople: Boolean = false, val metadata: ConversationMetadataUiState = ConversationMetadataUiState.Loading, val messages: ConversationMessagesUiState = ConversationMessagesUiState.Loading, val composer: ConversationComposerUiState = ConversationComposerUiState(), From 5851be7e9293b6c01d14a3dff59ae0646fa7ad18 Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Fri, 17 Apr 2026 17:41:12 +0300 Subject: [PATCH 39/99] Open conversation detail screen on top bar click --- .../conversation/v2/ConversationActivity.kt | 8 ++++++++ .../v2/metadata/ui/ConversationTopAppBar.kt | 11 +++++++++++ .../v2/navigation/ConversationNavGraph.kt | 19 ++++++++++++------- .../v2/screen/ConversationScreen.kt | 8 ++++++-- 4 files changed, 37 insertions(+), 9 deletions(-) diff --git a/src/com/android/messaging/ui/conversation/v2/ConversationActivity.kt b/src/com/android/messaging/ui/conversation/v2/ConversationActivity.kt index 5ccd39cc..f3d78357 100644 --- a/src/com/android/messaging/ui/conversation/v2/ConversationActivity.kt +++ b/src/com/android/messaging/ui/conversation/v2/ConversationActivity.kt @@ -38,6 +38,7 @@ internal class ConversationActivity : ComponentActivity() { AppTheme { ConversationNavGraph( launchRequest = launchRequest, + onConversationDetailsClick = ::launchConversationDetails, onFinish = ::finishAfterTransition, ) } @@ -104,6 +105,13 @@ internal class ConversationActivity : ComponentActivity() { .let(::startActivity) } + private fun launchConversationDetails(conversationId: String) { + UIIntents.get().launchPeopleAndOptionsActivity( + this, + conversationId, + ) + } + private companion object { private const val LAUNCH_GENERATION_STATE_KEY = "launch_generation" } diff --git a/src/com/android/messaging/ui/conversation/v2/metadata/ui/ConversationTopAppBar.kt b/src/com/android/messaging/ui/conversation/v2/metadata/ui/ConversationTopAppBar.kt index e518e856..9218ee3a 100644 --- a/src/com/android/messaging/ui/conversation/v2/metadata/ui/ConversationTopAppBar.kt +++ b/src/com/android/messaging/ui/conversation/v2/metadata/ui/ConversationTopAppBar.kt @@ -1,5 +1,6 @@ package com.android.messaging.ui.conversation.v2.metadata.ui +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -48,17 +49,21 @@ internal fun ConversationTopAppBar( metadata: ConversationMetadataUiState, isAddPeopleVisible: Boolean = false, onAddPeopleClick: () -> Unit, + onTitleClick: () -> Unit, onNavigateBack: () -> Unit, ) { val presentation = rememberConversationTopAppBarPresentation( metadata = metadata, ) + val isTitleClickable = metadata is ConversationMetadataUiState.Present TopAppBar( modifier = modifier.fillMaxWidth(), colors = conversationTopAppBarColors(), title = { ConversationTopAppBarTitle( + isClickable = isTitleClickable, + onClick = onTitleClick, presentation = presentation, ) }, @@ -117,9 +122,15 @@ private fun rememberConversationTopAppBarPresentation( @Composable private fun ConversationTopAppBarTitle( + isClickable: Boolean, + onClick: () -> Unit, presentation: ConversationTopAppBarPresentation, ) { Row( + modifier = Modifier.clickable( + enabled = isClickable, + onClick = onClick, + ), horizontalArrangement = Arrangement.spacedBy( space = CONVERSATION_TOP_APP_BAR_TITLE_SPACING, ), diff --git a/src/com/android/messaging/ui/conversation/v2/navigation/ConversationNavGraph.kt b/src/com/android/messaging/ui/conversation/v2/navigation/ConversationNavGraph.kt index 7e3f1473..4b13fb76 100644 --- a/src/com/android/messaging/ui/conversation/v2/navigation/ConversationNavGraph.kt +++ b/src/com/android/messaging/ui/conversation/v2/navigation/ConversationNavGraph.kt @@ -31,16 +31,18 @@ import com.android.messaging.util.UiUtils internal fun ConversationNavGraph( launchRequest: ConversationEntryLaunchRequest?, modifier: Modifier = Modifier, + onConversationDetailsClick: (String) -> Unit = {}, onFinish: () -> Unit, entryModel: ConversationEntryModel = hiltViewModel(), - navigationReducer: ConversationNavigationReducer = DEFAULT_CONVERSATION_NAVIGATION_REDUCER, + navigationReducer: ConversationNavigationReducer = defaultConversationNavReducer, ) { val entryUiState by entryModel.uiState.collectAsStateWithLifecycle() - val backStack = rememberNavBackStack(initialNavKey(launchRequest = launchRequest)) - val latestEntryModel = rememberUpdatedState(newValue = entryModel) - val latestEntryUiState = rememberUpdatedState(newValue = entryUiState) - val latestNavigationReducer = rememberUpdatedState(newValue = navigationReducer) - val latestOnFinish = rememberUpdatedState(newValue = onFinish) + val backStack = rememberNavBackStack(initialNavKey(launchRequest)) + val latestEntryModel = rememberUpdatedState(entryModel) + val latestEntryUiState = rememberUpdatedState(entryUiState) + val latestNavigationReducer = rememberUpdatedState(navigationReducer) + val latestOnConversationDetailsClick = rememberUpdatedState(onConversationDetailsClick) + val latestOnFinish = rememberUpdatedState(onFinish) val entryDecorators = listOf( rememberSaveableStateHolderNavEntryDecorator(), @@ -65,6 +67,9 @@ internal fun ConversationNavGraph( conversationId = navKey.conversationId, ) }, + onConversationDetailsClick = { + latestOnConversationDetailsClick.value(navKey.conversationId) + }, onNavigateBack = { popBackStackOrFinish( backStack = backStack, @@ -313,5 +318,5 @@ private fun handleEntryEffect( } } -private val DEFAULT_CONVERSATION_NAVIGATION_REDUCER: ConversationNavigationReducer = +private val defaultConversationNavReducer: ConversationNavigationReducer = ConversationNavigationReducerImpl() diff --git a/src/com/android/messaging/ui/conversation/v2/screen/ConversationScreen.kt b/src/com/android/messaging/ui/conversation/v2/screen/ConversationScreen.kt index 594955c0..b92258b8 100644 --- a/src/com/android/messaging/ui/conversation/v2/screen/ConversationScreen.kt +++ b/src/com/android/messaging/ui/conversation/v2/screen/ConversationScreen.kt @@ -45,8 +45,9 @@ internal fun ConversationScreen( modifier: Modifier = Modifier, conversationId: String? = null, launchGeneration: Int? = null, - onAddPeopleClick: () -> Unit = {}, - onNavigateBack: () -> Unit = {}, + onAddPeopleClick: () -> Unit, + onConversationDetailsClick: () -> Unit, + onNavigateBack: () -> Unit, pendingDraft: ConversationDraft? = null, pendingStartupAttachment: ConversationEntryStartupAttachment? = null, onPendingDraftConsumed: () -> Unit = {}, @@ -130,6 +131,7 @@ internal fun ConversationScreen( isMediaPickerOpen = mediaPickerState.isOpen, messageFieldFocusRequester = messageFieldFocusRequester, onAddPeopleClick = onAddPeopleClick, + onConversationDetailsClick = onConversationDetailsClick, onNavigateBack = onNavigateBack, onOpenMediaPicker = mediaPickerState::open, onMessageTextChange = screenModel::onMessageTextChanged, @@ -169,6 +171,7 @@ private fun ConversationScreenScaffold( isMediaPickerOpen: Boolean, messageFieldFocusRequester: FocusRequester, onAddPeopleClick: () -> Unit, + onConversationDetailsClick: () -> Unit, onNavigateBack: () -> Unit, onOpenMediaPicker: () -> Unit, onMessageTextChange: (String) -> Unit, @@ -186,6 +189,7 @@ private fun ConversationScreenScaffold( metadata = uiState.metadata, isAddPeopleVisible = uiState.canAddPeople, onAddPeopleClick = onAddPeopleClick, + onTitleClick = onConversationDetailsClick, onNavigateBack = onNavigateBack, ) }, From 79fea46147e435504cacd7714176dfd91d0190f6 Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Fri, 17 Apr 2026 23:02:16 +0300 Subject: [PATCH 40/99] Extract message selection into delegate --- .../repository/ConversationsRepository.kt | 146 +++++- .../conversation/ConversationBindsModule.kt | 16 + .../ConversationViewModelBindsModule.kt | 8 + .../messaging/di/core/CoreProvidesModule.kt | 10 + .../usecase/CreateForwardedMessage.kt | 58 +++ .../ForwardedMessageSubjectFormatter.kt | 30 ++ .../ConversationMessageSelectionDelegate.kt | 431 ++++++++++++++++++ .../ConversationMessageUiModelMapper.kt | 4 + .../message/ConversationMessageUiModel.kt | 4 + .../v2/screen/ConversationScreenEffects.kt | 64 +++ .../screen/ConversationSelectionTopAppBar.kt | 222 +++++++++ .../v2/screen/ConversationViewModel.kt | 47 +- .../ConversationMessageSelectionUiState.kt | 36 ++ .../screen/model/ConversationScreenEffect.kt | 20 + .../ConversationScreenScaffoldUiState.kt | 1 + 15 files changed, 1095 insertions(+), 2 deletions(-) create mode 100644 src/com/android/messaging/domain/conversation/usecase/CreateForwardedMessage.kt create mode 100644 src/com/android/messaging/domain/conversation/usecase/ForwardedMessageSubjectFormatter.kt create mode 100644 src/com/android/messaging/ui/conversation/v2/messages/delegate/ConversationMessageSelectionDelegate.kt create mode 100644 src/com/android/messaging/ui/conversation/v2/screen/ConversationSelectionTopAppBar.kt create mode 100644 src/com/android/messaging/ui/conversation/v2/screen/model/ConversationMessageSelectionUiState.kt diff --git a/src/com/android/messaging/data/conversation/repository/ConversationsRepository.kt b/src/com/android/messaging/data/conversation/repository/ConversationsRepository.kt index 35ccc967..c3fdb74f 100644 --- a/src/com/android/messaging/data/conversation/repository/ConversationsRepository.kt +++ b/src/com/android/messaging/data/conversation/repository/ConversationsRepository.kt @@ -6,9 +6,15 @@ import android.net.Uri import com.android.messaging.data.conversation.model.metadata.ConversationComposerAvailability import com.android.messaging.data.conversation.model.metadata.ConversationMetadata import com.android.messaging.datamodel.DatabaseHelper.ConversationColumns +import com.android.messaging.datamodel.DatabaseHelper.ParticipantColumns import com.android.messaging.datamodel.MessagingContentProvider +import com.android.messaging.datamodel.action.DeleteMessageAction +import com.android.messaging.datamodel.action.RedownloadMmsAction +import com.android.messaging.datamodel.action.ResendMessageAction import com.android.messaging.datamodel.data.ConversationListItemData import com.android.messaging.datamodel.data.ConversationMessageData +import com.android.messaging.datamodel.data.ConversationParticipantsData +import com.android.messaging.datamodel.data.ParticipantData import com.android.messaging.di.core.IoDispatcher import com.android.messaging.util.db.ReversedCursor import com.android.messaging.util.db.ext.getInt @@ -25,8 +31,29 @@ import kotlinx.coroutines.flow.map internal interface ConversationsRepository { fun getConversationMetadata(conversationId: String): Flow fun getConversationMessages(conversationId: String): Flow> + fun getConversationMessage( + conversationId: String, + messageId: String, + ): ConversationMessageData? + + fun deleteMessages(messageIds: Collection) + + fun downloadMessage(messageId: String) + + fun getMessageDetailsData( + conversationId: String, + messageId: String, + ): ConversationMessageDetailsData? + + fun resendMessage(messageId: String) } +internal data class ConversationMessageDetailsData( + val message: ConversationMessageData, + val participants: ConversationParticipantsData, + val selfParticipant: ParticipantData?, +) + internal class ConversationsRepositoryImpl @Inject constructor( private val contentResolver: ContentResolver, @param:IoDispatcher @@ -56,6 +83,58 @@ internal class ConversationsRepositoryImpl @Inject constructor( .flowOn(ioDispatcher) } + override fun getConversationMessage( + conversationId: String, + messageId: String, + ): ConversationMessageData? { + return getConversationMessageData( + conversationId = conversationId, + messageId = messageId, + ) + } + + override fun deleteMessages(messageIds: Collection) { + messageIds + .asSequence() + .filter(String::isNotBlank) + .forEach(DeleteMessageAction::deleteMessage) + } + + override fun downloadMessage(messageId: String) { + messageId + .takeIf { it.isNotBlank() } + ?.let(RedownloadMmsAction::redownloadMessage) + } + + override fun getMessageDetailsData( + conversationId: String, + messageId: String, + ): ConversationMessageDetailsData? { + val message = getConversationMessageData( + conversationId = conversationId, + messageId = messageId, + ) ?: return null + + val participants = queryConversationParticipants( + conversationId = conversationId, + ) + val selfParticipant = queryParticipant( + participantId = message.selfParticipantId, + ) + + return ConversationMessageDetailsData( + message = message, + participants = participants, + selfParticipant = selfParticipant, + ) + } + + override fun resendMessage(messageId: String) { + messageId + .takeIf { it.isNotBlank() } + ?.let(ResendMessageAction::resendMessage) + } + private fun observeUri(uri: Uri): Flow { return callbackFlow { val observer = object : ContentObserver(null) { @@ -73,6 +152,26 @@ internal class ConversationsRepositoryImpl @Inject constructor( } } + private fun getConversationMessageData( + conversationId: String, + messageId: String, + ): ConversationMessageData? { + if (conversationId.isBlank() || messageId.isBlank()) { + return null + } + + return when { + conversationId.isBlank() || messageId.isBlank() -> null + + else -> { + MessagingContentProvider + .buildConversationMessagesUri(conversationId) + .let(::queryConversationMessages) + .firstOrNull { it.messageId == messageId } + } + } + } + private fun queryConversationMetadata(uri: Uri): ConversationMetadata? { return contentResolver .query( @@ -90,7 +189,7 @@ internal class ConversationsRepositoryImpl @Inject constructor( ConversationMetadata( conversationName = cursor.getStringOrEmpty(ConversationColumns.NAME), selfParticipantId = cursor.getStringOrEmpty( - ConversationColumns.CURRENT_SELF_ID + ConversationColumns.CURRENT_SELF_ID, ), isGroupConversation = cursor.getInt(ConversationColumns.PARTICIPANT_COUNT) > 1, participantCount = cursor.getInt(ConversationColumns.PARTICIPANT_COUNT), @@ -99,6 +198,51 @@ internal class ConversationsRepositoryImpl @Inject constructor( } } + private fun queryConversationParticipants( + conversationId: String, + ): ConversationParticipantsData { + val uri = MessagingContentProvider.buildConversationParticipantsUri(conversationId) + + return contentResolver + .query( + uri, + ParticipantData.ParticipantsQuery.PROJECTION, + null, + null, + null, + ) + ?.use { cursor -> + ConversationParticipantsData().apply { + bind(cursor) + } + } + ?: ConversationParticipantsData() + } + + private fun queryParticipant( + participantId: String?, + ): ParticipantData? { + if (participantId.isNullOrBlank()) { + return null + } + + return contentResolver + .query( + MessagingContentProvider.PARTICIPANTS_URI, + ParticipantData.ParticipantsQuery.PROJECTION, + "${ParticipantColumns._ID} = ?", + arrayOf(participantId), + null, + ) + ?.use { cursor -> + if (!cursor.moveToFirst()) { + return@use null + } + + ParticipantData.getFromCursor(cursor) + } + } + private fun queryConversationMessages(uri: Uri): List { return contentResolver .query( diff --git a/src/com/android/messaging/di/conversation/ConversationBindsModule.kt b/src/com/android/messaging/di/conversation/ConversationBindsModule.kt index f30b3768..b1b1fac0 100644 --- a/src/com/android/messaging/di/conversation/ConversationBindsModule.kt +++ b/src/com/android/messaging/di/conversation/ConversationBindsModule.kt @@ -22,6 +22,10 @@ import com.android.messaging.domain.contacts.usecase.IsReadContactsPermissionGra import com.android.messaging.domain.contacts.usecase.IsReadContactsPermissionGrantedImpl import com.android.messaging.domain.conversation.usecase.CanAddMoreConversationParticipants import com.android.messaging.domain.conversation.usecase.CanAddMoreConversationParticipantsImpl +import com.android.messaging.domain.conversation.usecase.CreateForwardedMessage +import com.android.messaging.domain.conversation.usecase.CreateForwardedMessageImpl +import com.android.messaging.domain.conversation.usecase.ForwardedMessageSubjectFormatter +import com.android.messaging.domain.conversation.usecase.ForwardedMessageSubjectFormatterImpl import com.android.messaging.domain.conversation.usecase.IsConversationRecipientLimitExceeded import com.android.messaging.domain.conversation.usecase.IsConversationRecipientLimitExceededImpl import com.android.messaging.domain.conversation.usecase.ResolveConversationId @@ -94,12 +98,24 @@ internal abstract class ConversationBindsModule { impl: CanAddMoreConversationParticipantsImpl, ): CanAddMoreConversationParticipants + @Binds + @Reusable + abstract fun bindCreateForwardedMessage( + impl: CreateForwardedMessageImpl, + ): CreateForwardedMessage + @Binds @Reusable abstract fun bindIsReadContactsPermissionGranted( impl: IsReadContactsPermissionGrantedImpl, ): IsReadContactsPermissionGranted + @Binds + @Reusable + abstract fun bindForwardedMessageSubjectFormatter( + impl: ForwardedMessageSubjectFormatterImpl, + ): ForwardedMessageSubjectFormatter + @Binds @Reusable abstract fun bindResolveConversationId( diff --git a/src/com/android/messaging/di/conversation/ConversationViewModelBindsModule.kt b/src/com/android/messaging/di/conversation/ConversationViewModelBindsModule.kt index 171b4fea..a6e39323 100644 --- a/src/com/android/messaging/di/conversation/ConversationViewModelBindsModule.kt +++ b/src/com/android/messaging/di/conversation/ConversationViewModelBindsModule.kt @@ -4,6 +4,8 @@ import com.android.messaging.ui.conversation.v2.composer.delegate.ConversationDr import com.android.messaging.ui.conversation.v2.composer.delegate.ConversationDraftDelegateImpl import com.android.messaging.ui.conversation.v2.mediapicker.ConversationMediaPickerDelegate import com.android.messaging.ui.conversation.v2.mediapicker.ConversationMediaPickerDelegateImpl +import com.android.messaging.ui.conversation.v2.messages.delegate.ConversationMessageSelectionDelegate +import com.android.messaging.ui.conversation.v2.messages.delegate.ConversationMessageSelectionDelegateImpl import com.android.messaging.ui.conversation.v2.messages.delegate.ConversationMessagesDelegate import com.android.messaging.ui.conversation.v2.messages.delegate.ConversationMessagesDelegateImpl import com.android.messaging.ui.conversation.v2.metadata.delegate.ConversationMetadataDelegate @@ -32,6 +34,12 @@ internal abstract class ConversationViewModelBindsModule { impl: ConversationMediaPickerDelegateImpl, ): ConversationMediaPickerDelegate + @Binds + @ViewModelScoped + abstract fun bindConversationMessageSelectionDelegate( + impl: ConversationMessageSelectionDelegateImpl, + ): ConversationMessageSelectionDelegate + @Binds @ViewModelScoped abstract fun bindConversationMessagesDelegate( diff --git a/src/com/android/messaging/di/core/CoreProvidesModule.kt b/src/com/android/messaging/di/core/CoreProvidesModule.kt index d22054c4..a3a04ec9 100644 --- a/src/com/android/messaging/di/core/CoreProvidesModule.kt +++ b/src/com/android/messaging/di/core/CoreProvidesModule.kt @@ -1,5 +1,6 @@ package com.android.messaging.di.core +import android.content.ClipboardManager import android.content.ContentResolver import android.content.Context import dagger.Module @@ -57,4 +58,13 @@ internal class CoreProvidesModule { ): ContentResolver { return context.contentResolver } + + @Provides + @Reusable + fun provideClipboardManager( + @ApplicationContext + context: Context, + ): ClipboardManager { + return context.getSystemService(ClipboardManager::class.java) + } } diff --git a/src/com/android/messaging/domain/conversation/usecase/CreateForwardedMessage.kt b/src/com/android/messaging/domain/conversation/usecase/CreateForwardedMessage.kt new file mode 100644 index 00000000..e7931e5e --- /dev/null +++ b/src/com/android/messaging/domain/conversation/usecase/CreateForwardedMessage.kt @@ -0,0 +1,58 @@ +package com.android.messaging.domain.conversation.usecase + +import com.android.messaging.data.conversation.repository.ConversationsRepository +import com.android.messaging.datamodel.data.MessageData +import com.android.messaging.datamodel.data.MessagePartData +import com.android.messaging.datamodel.data.PendingAttachmentData +import javax.inject.Inject + +internal interface CreateForwardedMessage { + operator fun invoke( + conversationId: String, + messageId: String, + ): MessageData? +} + +internal class CreateForwardedMessageImpl @Inject constructor( + private val conversationsRepository: ConversationsRepository, + private val forwardedMessageSubjectFormatter: ForwardedMessageSubjectFormatter, +) : CreateForwardedMessage { + + override operator fun invoke( + conversationId: String, + messageId: String, + ): MessageData? { + val message = conversationsRepository + .getConversationMessage( + conversationId = conversationId, + messageId = messageId, + ) + ?: return null + + val forwardedMessage = MessageData() + + forwardedMessage.mmsSubject = forwardedMessageSubjectFormatter.format( + subject = message.mmsSubject, + ) + + message + .parts + ?.map(::createForwardedPart) + ?.forEach(forwardedMessage::addPart) + + return forwardedMessage + } + + private fun createForwardedPart(part: MessagePartData): MessagePartData { + return when { + part.isText -> MessagePartData.createTextMessagePart(part.text) + + else -> { + PendingAttachmentData.createPendingAttachmentData( + part.contentType, + part.contentUri, + ) + } + } + } +} diff --git a/src/com/android/messaging/domain/conversation/usecase/ForwardedMessageSubjectFormatter.kt b/src/com/android/messaging/domain/conversation/usecase/ForwardedMessageSubjectFormatter.kt new file mode 100644 index 00000000..b5d4d90d --- /dev/null +++ b/src/com/android/messaging/domain/conversation/usecase/ForwardedMessageSubjectFormatter.kt @@ -0,0 +1,30 @@ +package com.android.messaging.domain.conversation.usecase + +import android.content.Context +import com.android.messaging.R +import com.android.messaging.sms.cleanseMmsSubject +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject + +internal interface ForwardedMessageSubjectFormatter { + fun format(subject: String?): String? +} + +internal class ForwardedMessageSubjectFormatterImpl @Inject constructor( + @param:ApplicationContext + private val context: Context, +) : ForwardedMessageSubjectFormatter { + + override fun format(subject: String?): String? { + val resources = context.resources + val originalSubject = cleanseMmsSubject( + resources = resources, + subject = subject, + ) ?: return null + + return resources.getString( + R.string.message_fwd, + originalSubject, + ) + } +} diff --git a/src/com/android/messaging/ui/conversation/v2/messages/delegate/ConversationMessageSelectionDelegate.kt b/src/com/android/messaging/ui/conversation/v2/messages/delegate/ConversationMessageSelectionDelegate.kt new file mode 100644 index 00000000..6790310e --- /dev/null +++ b/src/com/android/messaging/ui/conversation/v2/messages/delegate/ConversationMessageSelectionDelegate.kt @@ -0,0 +1,431 @@ +package com.android.messaging.ui.conversation.v2.messages.delegate + +import android.content.ClipData +import android.content.ClipboardManager +import com.android.messaging.data.conversation.repository.ConversationsRepository +import com.android.messaging.di.core.DefaultDispatcher +import com.android.messaging.domain.conversation.usecase.CreateForwardedMessage +import com.android.messaging.ui.conversation.v2.common.ConversationScreenDelegate +import com.android.messaging.ui.conversation.v2.messages.model.message.ConversationMessageUiModel +import com.android.messaging.ui.conversation.v2.messages.model.message.ConversationMessagesUiState +import com.android.messaging.ui.conversation.v2.screen.model.ConversationMessageDeleteConfirmationUiState +import com.android.messaging.ui.conversation.v2.screen.model.ConversationMessageSelectionAction +import com.android.messaging.ui.conversation.v2.screen.model.ConversationMessageSelectionUiState +import com.android.messaging.ui.conversation.v2.screen.model.ConversationScreenEffect +import javax.inject.Inject +import kotlinx.collections.immutable.ImmutableSet +import kotlinx.collections.immutable.persistentSetOf +import kotlinx.collections.immutable.toImmutableSet +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +internal interface ConversationMessageSelectionDelegate : + ConversationScreenDelegate { + val effects: Flow + + fun onMessageClick(messageId: String) + + fun onMessageLongClick(messageId: String) + + fun onMessageSelectionActionClick(action: ConversationMessageSelectionAction) + + fun dismissDeleteMessageConfirmation() + + fun dismissMessageSelection() + + fun confirmDeleteSelectedMessages() +} + +internal class ConversationMessageSelectionDelegateImpl @Inject constructor( + private val clipboardManager: ClipboardManager, + private val conversationMessagesDelegate: ConversationMessagesDelegate, + private val createForwardedMessage: CreateForwardedMessage, + private val conversationsRepository: ConversationsRepository, + @param:DefaultDispatcher + private val defaultDispatcher: CoroutineDispatcher, +) : ConversationMessageSelectionDelegate { + + private val _effects = MutableSharedFlow( + extraBufferCapacity = 1, + ) + private val _state = MutableStateFlow(ConversationMessageSelectionUiState()) + private val messageSelectionState = MutableStateFlow( + ConversationMessageSelectionState(), + ) + + override val effects = _effects.asSharedFlow() + override val state = _state.asStateFlow() + + private var boundScope: CoroutineScope? = null + + override fun bind( + scope: CoroutineScope, + conversationIdFlow: StateFlow, + ) { + if (boundScope != null) { + return + } + + boundScope = scope + + bindSelectionUiState(scope = scope) + bindConversationChanges( + scope = scope, + conversationIdFlow = conversationIdFlow, + ) + } + + override fun onMessageClick(messageId: String) { + if (state.value.isSelectionMode) { + toggleMessageSelection(messageId = messageId) + } + } + + override fun onMessageLongClick(messageId: String) { + toggleMessageSelection(messageId = messageId) + } + + override fun onMessageSelectionActionClick(action: ConversationMessageSelectionAction) { + when (action) { + ConversationMessageSelectionAction.Copy -> { + copySelectedMessageText() + } + + ConversationMessageSelectionAction.Delete -> { + requestDeleteSelectedMessages() + } + + ConversationMessageSelectionAction.Details -> { + openSelectedMessageDetails() + } + + ConversationMessageSelectionAction.Download -> { + downloadSelectedMessage() + } + + ConversationMessageSelectionAction.Forward -> { + forwardSelectedMessage() + } + + ConversationMessageSelectionAction.Resend -> { + resendSelectedMessage() + } + + ConversationMessageSelectionAction.Share -> { + shareSelectedMessage() + } + } + } + + override fun dismissDeleteMessageConfirmation() { + messageSelectionState.update { currentState -> + currentState.copy( + pendingDeleteMessageIds = persistentSetOf(), + ) + } + } + + override fun dismissMessageSelection() { + clearMessageSelection() + } + + override fun confirmDeleteSelectedMessages() { + val deleteConfirmation = state.value.deleteConfirmation ?: return + + clearMessageSelection() + conversationsRepository.deleteMessages( + messageIds = deleteConfirmation.messageIds, + ) + } + + private fun bindSelectionUiState(scope: CoroutineScope) { + scope.launch(defaultDispatcher) { + combine( + conversationMessagesDelegate.state, + messageSelectionState, + ) { messagesUiState, selectionState -> + buildMessageSelectionUiState( + messagesUiState = messagesUiState, + selectionState = selectionState, + ) + }.collect { selectionUiState -> + _state.value = selectionUiState + } + } + } + + private fun bindConversationChanges( + scope: CoroutineScope, + conversationIdFlow: StateFlow, + ) { + scope.launch(defaultDispatcher) { + conversationIdFlow.collect { + clearMessageSelection() + } + } + } + + private fun clearMessageSelection() { + messageSelectionState.value = ConversationMessageSelectionState() + } + + private fun copySelectedMessageText() { + val selectedMessage = singleSelectedMessageOrNull() ?: return + val text = selectedMessage.text?.takeIf(String::isNotBlank) ?: return + + clipboardManager.setPrimaryClip( + ClipData.newPlainText( + null, + text, + ), + ) + + clearMessageSelection() + } + + private fun downloadSelectedMessage() { + val selectedMessage = singleSelectedMessageOrNull() ?: return + + clearMessageSelection() + conversationsRepository.downloadMessage(selectedMessage.messageId) + } + + private fun emitEffect(effect: ConversationScreenEffect) { + boundScope?.launch(defaultDispatcher) { + _effects.emit(effect) + } + } + + private fun forwardSelectedMessage() { + val selectedMessage = singleSelectedMessageOrNull() ?: return + + clearMessageSelection() + + boundScope?.launch(defaultDispatcher) { + val forwardedMessage = createForwardedMessage( + conversationId = selectedMessage.conversationId, + messageId = selectedMessage.messageId, + ) ?: return@launch + + _effects.emit( + ConversationScreenEffect.LaunchForwardMessage( + message = forwardedMessage, + ), + ) + } + } + + private fun openSelectedMessageDetails() { + val selectedMessage = singleSelectedMessageOrNull() ?: return + + clearMessageSelection() + boundScope?.launch(defaultDispatcher) { + conversationsRepository + .getMessageDetailsData( + conversationId = selectedMessage.conversationId, + messageId = selectedMessage.messageId, + ) + ?.let { messageDetailsData -> + ConversationScreenEffect.ShowMessageDetails( + message = messageDetailsData.message, + participants = messageDetailsData.participants, + selfParticipant = messageDetailsData.selfParticipant, + ) + }?.let { effect -> + _effects.emit(effect) + } + } + } + + private fun requestDeleteSelectedMessages() { + val selectedMessageIds = state.value.selectedMessageIds + + if (selectedMessageIds.isEmpty()) { + return + } + + messageSelectionState.update { currentState -> + currentState.copy( + pendingDeleteMessageIds = selectedMessageIds, + ) + } + } + + private fun resendSelectedMessage() { + val selectedMessage = singleSelectedMessageOrNull() ?: return + + clearMessageSelection() + + conversationsRepository.resendMessage( + messageId = selectedMessage.messageId, + ) + } + + private fun singleSelectedMessageOrNull(): ConversationMessageUiModel? { + val messagesUiState = conversationMessagesDelegate.state.value + val selectedMessageIds = state + .value + .selectedMessageIds + .takeIf { it.size == 1 } + ?: return null + + return when (messagesUiState) { + is ConversationMessagesUiState.Present -> { + messagesUiState.messages.firstOrNull { message -> + message.messageId == selectedMessageIds.first() + } + } + + ConversationMessagesUiState.Loading -> null + } + } + + private fun shareSelectedMessage() { + val selectedMessage = singleSelectedMessageOrNull() ?: return + val messageText = selectedMessage.text?.takeIf(String::isNotBlank) + + val firstAttachment = when { + messageText != null -> null + else -> { + selectedMessage.parts.firstOrNull { part -> + !part.contentType.isBlank() && part.contentUri != null + } + } + } + + clearMessageSelection() + emitEffect( + effect = ConversationScreenEffect.ShareMessage( + attachmentContentType = firstAttachment?.contentType, + attachmentContentUri = firstAttachment?.contentUri?.toString(), + text = messageText, + ), + ) + } + + private fun toggleMessageSelection(messageId: String) { + if (messageId.isBlank()) { + return + } + + val selectedMessageIds = state.value.selectedMessageIds + + val updatedMessageIds = when { + selectedMessageIds.contains(messageId) -> { + (selectedMessageIds - messageId).toImmutableSet() + } + + else -> { + (selectedMessageIds + messageId).toImmutableSet() + } + } + + messageSelectionState.update { currentState -> + currentState.copy( + selectedMessageIds = updatedMessageIds, + pendingDeleteMessageIds = persistentSetOf(), + ) + } + } +} + +private fun buildMessageSelectionUiState( + messagesUiState: ConversationMessagesUiState, + selectionState: ConversationMessageSelectionState, +): ConversationMessageSelectionUiState { + val messages = when (messagesUiState) { + is ConversationMessagesUiState.Present -> messagesUiState.messages + ConversationMessagesUiState.Loading -> return ConversationMessageSelectionUiState() + } + + val messagesById = messages.associateBy(ConversationMessageUiModel::messageId) + val currentMessageIds = messagesById.keys + + val selectedMessageIds = selectionState + .selectedMessageIds + .asSequence() + .filter(currentMessageIds::contains) + .toImmutableSet() + + val pendingDeleteMessageIds = selectionState + .pendingDeleteMessageIds + .asSequence() + .filter(currentMessageIds::contains) + .toImmutableSet() + + val selectedMessage = when (selectedMessageIds.size) { + 1 -> messagesById[selectedMessageIds.first()] + else -> null + } + + return ConversationMessageSelectionUiState( + selectedMessageIds = selectedMessageIds, + availableActions = availableSelectionActions( + selectedMessage = selectedMessage, + selectedMessageCount = selectedMessageIds.size, + ), + deleteConfirmation = pendingDeleteMessageIds + .takeIf { messageIds -> + messageIds.isNotEmpty() + } + ?.let { messageIds -> + ConversationMessageDeleteConfirmationUiState( + messageIds = messageIds, + ) + }, + ) +} + +private fun availableSelectionActions( + selectedMessage: ConversationMessageUiModel?, + selectedMessageCount: Int, +): ImmutableSet { + if (selectedMessageCount <= 0) { + return persistentSetOf() + } + + if (selectedMessageCount > 1 || selectedMessage == null) { + return persistentSetOf( + ConversationMessageSelectionAction.Delete, + ) + } + + val actions = LinkedHashSet() + + if (selectedMessage.canDownloadMessage) { + actions += ConversationMessageSelectionAction.Download + } + + if (selectedMessage.canResendMessage) { + actions += ConversationMessageSelectionAction.Resend + } + + actions += ConversationMessageSelectionAction.Delete + + if (selectedMessage.canForwardMessage) { + actions += ConversationMessageSelectionAction.Share + actions += ConversationMessageSelectionAction.Forward + } + + if (selectedMessage.canCopyMessageToClipboard) { + actions += ConversationMessageSelectionAction.Copy + } + + actions += ConversationMessageSelectionAction.Details + + return actions.toImmutableSet() +} + +private data class ConversationMessageSelectionState( + val selectedMessageIds: ImmutableSet = persistentSetOf(), + val pendingDeleteMessageIds: ImmutableSet = persistentSetOf(), +) diff --git a/src/com/android/messaging/ui/conversation/v2/messages/mapper/ConversationMessageUiModelMapper.kt b/src/com/android/messaging/ui/conversation/v2/messages/mapper/ConversationMessageUiModelMapper.kt index 69109761..a0eeb125 100644 --- a/src/com/android/messaging/ui/conversation/v2/messages/mapper/ConversationMessageUiModelMapper.kt +++ b/src/com/android/messaging/ui/conversation/v2/messages/mapper/ConversationMessageUiModelMapper.kt @@ -36,6 +36,10 @@ internal class ConversationMessageUiModelMapperImpl @Inject constructor() : senderContactLookupKey = data.senderContactLookupKey, canClusterWithPrevious = data.canClusterWithPreviousMessage, canClusterWithNext = data.canClusterWithNextMessage, + canCopyMessageToClipboard = data.canCopyMessageToClipboard, + canDownloadMessage = data.showDownloadMessage, + canForwardMessage = data.canForwardMessage, + canResendMessage = data.showResendMessage, mmsSubject = data.mmsSubject, protocol = mapProtocol(data), ) diff --git a/src/com/android/messaging/ui/conversation/v2/messages/model/message/ConversationMessageUiModel.kt b/src/com/android/messaging/ui/conversation/v2/messages/model/message/ConversationMessageUiModel.kt index 240f7a8d..b526b19f 100644 --- a/src/com/android/messaging/ui/conversation/v2/messages/model/message/ConversationMessageUiModel.kt +++ b/src/com/android/messaging/ui/conversation/v2/messages/model/message/ConversationMessageUiModel.kt @@ -19,6 +19,10 @@ internal data class ConversationMessageUiModel( val senderContactLookupKey: String?, val canClusterWithPrevious: Boolean, val canClusterWithNext: Boolean, + val canCopyMessageToClipboard: Boolean, + val canDownloadMessage: Boolean, + val canForwardMessage: Boolean, + val canResendMessage: Boolean, val mmsSubject: String?, val protocol: Protocol, ) { diff --git a/src/com/android/messaging/ui/conversation/v2/screen/ConversationScreenEffects.kt b/src/com/android/messaging/ui/conversation/v2/screen/ConversationScreenEffects.kt index a2cd9082..e0c00c00 100644 --- a/src/com/android/messaging/ui/conversation/v2/screen/ConversationScreenEffects.kt +++ b/src/com/android/messaging/ui/conversation/v2/screen/ConversationScreenEffects.kt @@ -14,6 +14,7 @@ import androidx.compose.ui.platform.LocalContext import androidx.core.net.toUri import com.android.messaging.R import com.android.messaging.ui.UIIntents +import com.android.messaging.ui.conversation.MessageDetailsDialog import com.android.messaging.ui.conversation.v2.screen.model.ConversationScreenEffect import com.android.messaging.util.ContentType import com.android.messaging.util.UiUtils @@ -56,9 +57,34 @@ internal fun ConversationScreenEffects( ) } + is ConversationScreenEffect.LaunchForwardMessage -> { + UIIntents.get().launchForwardMessageActivity( + context, + effect.message, + ) + } + + is ConversationScreenEffect.ShareMessage -> { + openShareSheet( + context = context, + attachmentContentType = effect.attachmentContentType, + attachmentContentUri = effect.attachmentContentUri, + text = effect.text, + ) + } + is ConversationScreenEffect.ShowMessage -> { UiUtils.showToastAtBottom(effect.messageResId) } + + is ConversationScreenEffect.ShowMessageDetails -> { + MessageDetailsDialog.show( + context, + effect.message, + effect.participants, + effect.selfParticipant, + ) + } } } } @@ -71,6 +97,44 @@ private fun openExternalUri( UIIntents.get().launchBrowserForUrl(context, uri) } +private suspend fun openShareSheet( + context: Context, + attachmentContentType: String?, + attachmentContentUri: String?, + text: String?, +) { + val shareIntent = Intent(Intent.ACTION_SEND) + + if ( + !attachmentContentType.isNullOrBlank() && + !attachmentContentUri.isNullOrBlank() + ) { + val normalizedAttachmentUri = normalizeAttachmentUriForIntent( + attachmentUri = attachmentContentUri.toUri(), + ) + + shareIntent.putExtra( + Intent.EXTRA_STREAM, + normalizedAttachmentUri, + ) + shareIntent.setType(attachmentContentType) + shareIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } else { + shareIntent.putExtra( + Intent.EXTRA_TEXT, + text.orEmpty(), + ) + shareIntent.setType("text/plain") + } + + context.startActivity( + Intent.createChooser( + shareIntent, + context.getText(R.string.action_share), + ), + ) +} + private suspend fun openAttachmentPreview( context: Context, hostBounds: ComposeRect?, diff --git a/src/com/android/messaging/ui/conversation/v2/screen/ConversationSelectionTopAppBar.kt b/src/com/android/messaging/ui/conversation/v2/screen/ConversationSelectionTopAppBar.kt new file mode 100644 index 00000000..30502e98 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/v2/screen/ConversationSelectionTopAppBar.kt @@ -0,0 +1,222 @@ +package com.android.messaging.ui.conversation.v2.screen + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.Forward +import androidx.compose.material.icons.automirrored.rounded.Send +import androidx.compose.material.icons.rounded.Close +import androidx.compose.material.icons.rounded.ContentCopy +import androidx.compose.material.icons.rounded.Delete +import androidx.compose.material.icons.rounded.FileDownload +import androidx.compose.material.icons.rounded.Info +import androidx.compose.material.icons.rounded.MoreVert +import androidx.compose.material.icons.rounded.Share +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarColors +import androidx.compose.material3.TopAppBarDefaults +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.graphics.vector.ImageVector +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import com.android.messaging.R +import com.android.messaging.ui.conversation.v2.screen.model.ConversationMessageSelectionAction +import com.android.messaging.ui.conversation.v2.screen.model.ConversationMessageSelectionUiState + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun ConversationSelectionTopAppBar( + selection: ConversationMessageSelectionUiState, + onActionClick: (ConversationMessageSelectionAction) -> Unit, + onDismissSelection: () -> Unit, +) { + var isOverflowExpanded by remember { + mutableStateOf(value = false) + } + val overflowActions = remember(selection.availableActions) { + buildList { + if (selection.availableActions.contains(ConversationMessageSelectionAction.Share)) { + add(ConversationMessageSelectionAction.Share) + } + if (selection.availableActions.contains(ConversationMessageSelectionAction.Forward)) { + add(ConversationMessageSelectionAction.Forward) + } + if (selection.availableActions.contains(ConversationMessageSelectionAction.Details)) { + add(ConversationMessageSelectionAction.Details) + } + } + } + + TopAppBar( + colors = conversationSelectionTopAppBarColors(), + title = { + Text( + text = pluralStringResource( + id = R.plurals.conversation_message_selection_title, + count = selection.selectedMessageCount, + selection.selectedMessageCount, + ), + ) + }, + navigationIcon = { + IconButton( + onClick = onDismissSelection, + ) { + Icon( + imageVector = Icons.Rounded.Close, + contentDescription = stringResource( + id = R.string.close_selection, + ), + ) + } + }, + actions = { + if (selection.availableActions.contains(ConversationMessageSelectionAction.Download)) { + ConversationSelectionActionButton( + action = ConversationMessageSelectionAction.Download, + onActionClick = onActionClick, + ) + } + + if (selection.availableActions.contains(ConversationMessageSelectionAction.Resend)) { + ConversationSelectionActionButton( + action = ConversationMessageSelectionAction.Resend, + onActionClick = onActionClick, + ) + } + + if (selection.availableActions.contains(ConversationMessageSelectionAction.Copy)) { + ConversationSelectionActionButton( + action = ConversationMessageSelectionAction.Copy, + onActionClick = onActionClick, + ) + } + + if (selection.availableActions.contains(ConversationMessageSelectionAction.Delete)) { + ConversationSelectionActionButton( + action = ConversationMessageSelectionAction.Delete, + onActionClick = onActionClick, + ) + } + + if (overflowActions.isNotEmpty()) { + IconButton( + onClick = { + isOverflowExpanded = true + }, + ) { + Icon( + imageVector = Icons.Rounded.MoreVert, + contentDescription = stringResource( + id = R.string.more_options, + ), + ) + } + + DropdownMenu( + expanded = isOverflowExpanded, + onDismissRequest = { + isOverflowExpanded = false + }, + ) { + overflowActions.forEach { action -> + DropdownMenuItem( + text = { + Text(text = selectionActionLabel(action = action)) + }, + onClick = { + isOverflowExpanded = false + onActionClick(action) + }, + leadingIcon = { + Icon( + imageVector = selectionActionIcon(action = action), + contentDescription = null, + ) + }, + ) + } + } + } + }, + ) +} + +@Composable +private fun conversationSelectionTopAppBarColors(): TopAppBarColors { + return TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer, + navigationIconContentColor = MaterialTheme.colorScheme.onSecondaryContainer, + titleContentColor = MaterialTheme.colorScheme.onSecondaryContainer, + actionIconContentColor = MaterialTheme.colorScheme.onSecondaryContainer, + ) +} + +@Composable +private fun ConversationSelectionActionButton( + action: ConversationMessageSelectionAction, + onActionClick: (ConversationMessageSelectionAction) -> Unit, +) { + IconButton( + onClick = { + onActionClick(action) + }, + ) { + Icon( + imageVector = selectionActionIcon(action = action), + contentDescription = selectionActionLabel(action = action), + ) + } +} + +private fun selectionActionIcon( + action: ConversationMessageSelectionAction, +): ImageVector { + return when (action) { + ConversationMessageSelectionAction.Copy -> Icons.Rounded.ContentCopy + ConversationMessageSelectionAction.Delete -> Icons.Rounded.Delete + ConversationMessageSelectionAction.Details -> Icons.Rounded.Info + ConversationMessageSelectionAction.Download -> Icons.Rounded.FileDownload + ConversationMessageSelectionAction.Forward -> Icons.AutoMirrored.Rounded.Forward + ConversationMessageSelectionAction.Resend -> Icons.AutoMirrored.Rounded.Send + ConversationMessageSelectionAction.Share -> Icons.Rounded.Share + } +} + +@Composable +private fun selectionActionLabel( + action: ConversationMessageSelectionAction, +): String { + return when (action) { + ConversationMessageSelectionAction.Copy -> { + stringResource(id = R.string.message_context_menu_copy_text) + } + ConversationMessageSelectionAction.Delete -> { + stringResource(id = R.string.action_delete_message) + } + ConversationMessageSelectionAction.Details -> { + stringResource(id = R.string.message_context_menu_view_details) + } + ConversationMessageSelectionAction.Download -> { + stringResource(id = R.string.action_download) + } + ConversationMessageSelectionAction.Forward -> { + stringResource(id = R.string.message_context_menu_forward_message) + } + ConversationMessageSelectionAction.Resend -> { + stringResource(id = R.string.action_send) + } + ConversationMessageSelectionAction.Share -> { + stringResource(id = R.string.action_share) + } + } +} diff --git a/src/com/android/messaging/ui/conversation/v2/screen/ConversationViewModel.kt b/src/com/android/messaging/ui/conversation/v2/screen/ConversationViewModel.kt index 5f17266a..ab7cebe8 100644 --- a/src/com/android/messaging/ui/conversation/v2/screen/ConversationViewModel.kt +++ b/src/com/android/messaging/ui/conversation/v2/screen/ConversationViewModel.kt @@ -14,10 +14,12 @@ import com.android.messaging.ui.conversation.v2.composer.model.ConversationCompo import com.android.messaging.ui.conversation.v2.entry.model.ConversationEntryStartupAttachment import com.android.messaging.ui.conversation.v2.mediapicker.ConversationMediaPickerDelegate import com.android.messaging.ui.conversation.v2.mediapicker.model.ConversationCapturedMedia +import com.android.messaging.ui.conversation.v2.messages.delegate.ConversationMessageSelectionDelegate import com.android.messaging.ui.conversation.v2.messages.delegate.ConversationMessagesDelegate import com.android.messaging.ui.conversation.v2.metadata.delegate.ConversationMetadataDelegate import com.android.messaging.ui.conversation.v2.metadata.model.ConversationMetadataUiState import com.android.messaging.ui.conversation.v2.screen.model.ConversationMediaPickerOverlayUiState +import com.android.messaging.ui.conversation.v2.screen.model.ConversationMessageSelectionAction import com.android.messaging.ui.conversation.v2.screen.model.ConversationScreenEffect import com.android.messaging.ui.conversation.v2.screen.model.ConversationScreenScaffoldUiState import dagger.hilt.android.lifecycle.HiltViewModel @@ -57,6 +59,10 @@ internal interface ConversationScreenModel { contentUri: String, ) + fun onMessageClick(messageId: String) + fun onMessageLongClick(messageId: String) + fun onMessageSelectionActionClick(action: ConversationMessageSelectionAction) + fun onExternalUriClicked(uri: String) fun onGalleryMediaConfirmed(mediaItems: List) @@ -70,6 +76,9 @@ internal interface ConversationScreenModel { captionText: String, ) + fun dismissDeleteMessageConfirmation() + fun dismissMessageSelection() + fun confirmDeleteSelectedMessages() fun onSendClick() fun persistDraft() } @@ -78,6 +87,7 @@ internal interface ConversationScreenModel { internal class ConversationViewModel @Inject constructor( private val conversationDraftDelegate: ConversationDraftDelegate, private val conversationMessagesDelegate: ConversationMessagesDelegate, + private val conversationMessageSelectionDelegate: ConversationMessageSelectionDelegate, private val conversationMediaPickerDelegate: ConversationMediaPickerDelegate, private val conversationMetadataDelegate: ConversationMetadataDelegate, private val conversationComposerUiStateMapper: ConversationComposerUiStateMapper, @@ -121,12 +131,14 @@ internal class ConversationViewModel @Inject constructor( conversationMetadataDelegate.state, conversationMessagesDelegate.state, composerUiState, - ) { metadataState, messagesUiState, composerUiState -> + conversationMessageSelectionDelegate.state, + ) { metadataState, messagesUiState, composerUiState, selectionUiState -> ConversationScreenScaffoldUiState( canAddPeople = canAddPeople(metadataState = metadataState), metadata = metadataState, messages = messagesUiState, composer = composerUiState, + selection = selectionUiState, ) }.stateIn( scope = viewModelScope, @@ -140,6 +152,7 @@ internal class ConversationViewModel @Inject constructor( metadata = conversationMetadataDelegate.state.value, messages = conversationMessagesDelegate.state.value, composer = composerUiState.value, + selection = conversationMessageSelectionDelegate.state.value, ), ) @@ -190,6 +203,10 @@ internal class ConversationViewModel @Inject constructor( scope = viewModelScope, conversationIdFlow = conversationIdFlow, ) + conversationMessageSelectionDelegate.bind( + scope = viewModelScope, + conversationIdFlow = conversationIdFlow, + ) conversationMetadataDelegate.bind( scope = viewModelScope, conversationIdFlow = conversationIdFlow, @@ -201,6 +218,9 @@ internal class ConversationViewModel @Inject constructor( viewModelScope.launch(defaultDispatcher) { conversationMediaPickerDelegate.effects.collect(_effects::emit) } + viewModelScope.launch(defaultDispatcher) { + conversationMessageSelectionDelegate.effects.collect(_effects::emit) + } } override fun onConversationIdChanged(conversationId: String?) { @@ -209,6 +229,7 @@ internal class ConversationViewModel @Inject constructor( private fun updateConversationId(conversationId: String?) { if (conversationId != conversationIdFlow.value) { + conversationMessageSelectionDelegate.dismissMessageSelection() savedStateHandle[CONVERSATION_ID_KEY] = conversationId } } @@ -296,6 +317,18 @@ internal class ConversationViewModel @Inject constructor( } } + override fun onMessageClick(messageId: String) { + conversationMessageSelectionDelegate.onMessageClick(messageId = messageId) + } + + override fun onMessageLongClick(messageId: String) { + conversationMessageSelectionDelegate.onMessageLongClick(messageId = messageId) + } + + override fun onMessageSelectionActionClick(action: ConversationMessageSelectionAction) { + conversationMessageSelectionDelegate.onMessageSelectionActionClick(action = action) + } + override fun onExternalUriClicked(uri: String) { viewModelScope.launch(defaultDispatcher) { _effects.emit( @@ -340,6 +373,18 @@ internal class ConversationViewModel @Inject constructor( ) } + override fun dismissDeleteMessageConfirmation() { + conversationMessageSelectionDelegate.dismissDeleteMessageConfirmation() + } + + override fun dismissMessageSelection() { + conversationMessageSelectionDelegate.dismissMessageSelection() + } + + override fun confirmDeleteSelectedMessages() { + conversationMessageSelectionDelegate.confirmDeleteSelectedMessages() + } + override fun onSendClick() { conversationDraftDelegate.onSendClick() } diff --git a/src/com/android/messaging/ui/conversation/v2/screen/model/ConversationMessageSelectionUiState.kt b/src/com/android/messaging/ui/conversation/v2/screen/model/ConversationMessageSelectionUiState.kt new file mode 100644 index 00000000..24fb2b08 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/v2/screen/model/ConversationMessageSelectionUiState.kt @@ -0,0 +1,36 @@ +package com.android.messaging.ui.conversation.v2.screen.model + +import androidx.compose.runtime.Immutable +import kotlinx.collections.immutable.ImmutableSet +import kotlinx.collections.immutable.persistentSetOf + +@Immutable +internal data class ConversationMessageSelectionUiState( + val selectedMessageIds: ImmutableSet = persistentSetOf(), + val availableActions: ImmutableSet = persistentSetOf(), + val deleteConfirmation: ConversationMessageDeleteConfirmationUiState? = null, +) { + val isSelectionMode: Boolean + get() = selectedMessageIds.isNotEmpty() + + val isMultiSelect: Boolean + get() = selectedMessageIds.size > 1 + + val selectedMessageCount: Int + get() = selectedMessageIds.size +} + +@Immutable +internal data class ConversationMessageDeleteConfirmationUiState( + val messageIds: ImmutableSet = persistentSetOf(), +) + +internal enum class ConversationMessageSelectionAction { + Copy, + Delete, + Details, + Download, + Forward, + Resend, + Share, +} diff --git a/src/com/android/messaging/ui/conversation/v2/screen/model/ConversationScreenEffect.kt b/src/com/android/messaging/ui/conversation/v2/screen/model/ConversationScreenEffect.kt index 9e701019..c93ece2c 100644 --- a/src/com/android/messaging/ui/conversation/v2/screen/model/ConversationScreenEffect.kt +++ b/src/com/android/messaging/ui/conversation/v2/screen/model/ConversationScreenEffect.kt @@ -1,6 +1,14 @@ package com.android.messaging.ui.conversation.v2.screen.model +import com.android.messaging.datamodel.data.ConversationMessageData +import com.android.messaging.datamodel.data.ConversationParticipantsData +import com.android.messaging.datamodel.data.MessageData +import com.android.messaging.datamodel.data.ParticipantData + internal sealed interface ConversationScreenEffect { + data class LaunchForwardMessage( + val message: MessageData, + ) : ConversationScreenEffect data class OpenAttachmentPreview( val contentType: String, @@ -12,7 +20,19 @@ internal sealed interface ConversationScreenEffect { val uri: String, ) : ConversationScreenEffect + data class ShareMessage( + val attachmentContentType: String?, + val attachmentContentUri: String?, + val text: String?, + ) : ConversationScreenEffect + data class ShowMessage( val messageResId: Int, ) : ConversationScreenEffect + + data class ShowMessageDetails( + val message: ConversationMessageData, + val participants: ConversationParticipantsData, + val selfParticipant: ParticipantData?, + ) : ConversationScreenEffect } diff --git a/src/com/android/messaging/ui/conversation/v2/screen/model/ConversationScreenScaffoldUiState.kt b/src/com/android/messaging/ui/conversation/v2/screen/model/ConversationScreenScaffoldUiState.kt index 796c5835..586eb499 100644 --- a/src/com/android/messaging/ui/conversation/v2/screen/model/ConversationScreenScaffoldUiState.kt +++ b/src/com/android/messaging/ui/conversation/v2/screen/model/ConversationScreenScaffoldUiState.kt @@ -11,4 +11,5 @@ internal data class ConversationScreenScaffoldUiState( val metadata: ConversationMetadataUiState = ConversationMetadataUiState.Loading, val messages: ConversationMessagesUiState = ConversationMessagesUiState.Loading, val composer: ConversationComposerUiState = ConversationComposerUiState(), + val selection: ConversationMessageSelectionUiState = ConversationMessageSelectionUiState(), ) From 9182fca0573acb2754b9687f1cedcf12fe0648c7 Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Fri, 17 Apr 2026 23:02:50 +0300 Subject: [PATCH 41/99] Add compose multi-message selection UI --- res/values/strings.xml | 10 + .../v2/messages/ui/ConversationMessages.kt | 21 ++ .../ConversationInlineAttachmentRow.kt | 8 +- .../ConversationMessageAttachments.kt | 4 + .../ConversationVisualAttachments.kt | 17 +- .../ui/message/ConversationMessage.kt | 229 ++++++++++++++++-- .../v2/screen/ConversationScreen.kt | 109 ++++++++- .../screen/ConversationSelectionTopAppBar.kt | 17 +- 8 files changed, 374 insertions(+), 41 deletions(-) diff --git a/res/values/strings.xml b/res/values/strings.xml index 702458a1..b2e32cca 100644 --- a/res/values/strings.xml +++ b/res/values/strings.xml @@ -58,6 +58,10 @@ The media is selected. The media is unselected. %d selected + + %d selected + %d selected + image %1$tB %1$te %1$tY %1$tl %1$tM %1$tp image @@ -264,6 +268,8 @@ Back + Close selection + More options Archived @@ -288,6 +294,10 @@ Delete this message? This action cannot be undone. Delete + + Delete this message? + Delete these %d messages? + diff --git a/src/com/android/messaging/ui/conversation/v2/messages/ui/ConversationMessages.kt b/src/com/android/messaging/ui/conversation/v2/messages/ui/ConversationMessages.kt index 62ca9273..f13e986e 100644 --- a/src/com/android/messaging/ui/conversation/v2/messages/ui/ConversationMessages.kt +++ b/src/com/android/messaging/ui/conversation/v2/messages/ui/ConversationMessages.kt @@ -30,6 +30,8 @@ import com.android.messaging.ui.conversation.v2.messages.ui.message.conversation import com.android.messaging.ui.conversation.v2.messages.ui.message.formatDateSeparatorText import java.util.TimeZone import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.ImmutableSet +import kotlinx.collections.immutable.persistentSetOf private val CONVERSATION_MESSAGES_CONTENT_PADDING = PaddingValues( start = 16.dp, @@ -56,8 +58,11 @@ internal fun ConversationMessages( modifier: Modifier = Modifier, messages: ImmutableList, listState: LazyListState, + selectedMessageIds: ImmutableSet = persistentSetOf(), onAttachmentClick: (contentType: String, contentUri: String) -> Unit, onExternalUriClick: (String) -> Unit, + onMessageClick: (String) -> Unit, + onMessageLongClick: (String) -> Unit, ) { val configuration = LocalConfiguration.current val displayMessages = remember(messages) { @@ -93,8 +98,12 @@ internal fun ConversationMessages( messages = displayMessages, index = index, ), + isSelectionMode = selectedMessageIds.isNotEmpty(), + isSelected = selectedMessageIds.contains(message.messageId), onAttachmentClick = onAttachmentClick, onExternalUriClick = onExternalUriClick, + onMessageClick = onMessageClick, + onMessageLongClick = onMessageLongClick, ) } } @@ -138,8 +147,12 @@ private fun messageAboveCurrent( private fun ConversationMessagesItem( message: ConversationMessageUiModel, messageAbove: ConversationMessageUiModel?, + isSelectionMode: Boolean, + isSelected: Boolean, onAttachmentClick: (contentType: String, contentUri: String) -> Unit, onExternalUriClick: (String) -> Unit, + onMessageClick: (String) -> Unit, + onMessageLongClick: (String) -> Unit, ) { val presentation = rememberConversationMessagesItemPresentation( message = message, @@ -154,9 +167,17 @@ private fun ConversationMessagesItem( modifier = Modifier .testTag(conversationMessageItemTestTag(messageId = message.messageId)) .padding(top = presentation.topPadding), + isSelected = isSelected, + isSelectionMode = isSelectionMode, message = message, onAttachmentClick = onAttachmentClick, onExternalUriClick = onExternalUriClick, + onMessageClick = { + onMessageClick(message.messageId) + }, + onMessageLongClick = { + onMessageLongClick(message.messageId) + }, ) } } diff --git a/src/com/android/messaging/ui/conversation/v2/messages/ui/attachment/ConversationInlineAttachmentRow.kt b/src/com/android/messaging/ui/conversation/v2/messages/ui/attachment/ConversationInlineAttachmentRow.kt index 5b6d8ea4..62f1a024 100644 --- a/src/com/android/messaging/ui/conversation/v2/messages/ui/attachment/ConversationInlineAttachmentRow.kt +++ b/src/com/android/messaging/ui/conversation/v2/messages/ui/attachment/ConversationInlineAttachmentRow.kt @@ -1,6 +1,6 @@ package com.android.messaging.ui.conversation.v2.messages.ui.attachment -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 @@ -32,6 +32,7 @@ internal fun ConversationInlineAttachmentRow( attachment: ConversationInlineAttachment, onAttachmentClick: (contentType: String, contentUri: String) -> Unit, onExternalUriClick: (String) -> Unit, + onLongClick: () -> Unit = {}, ) { val title = attachment.titleText ?: attachment.titleTextResId?.let { stringResource(it) }.orEmpty() @@ -54,11 +55,12 @@ internal fun ConversationInlineAttachmentRow( modifier = Modifier .fillMaxWidth() .clip(shape = shape) - .clickable( - enabled = onClick != null, + .combinedClickable( + enabled = true, onClick = { onClick?.invoke() }, + onLongClick = onLongClick, ), color = MaterialTheme.colorScheme.surfaceContainerHighest, shape = shape, diff --git a/src/com/android/messaging/ui/conversation/v2/messages/ui/attachment/ConversationMessageAttachments.kt b/src/com/android/messaging/ui/conversation/v2/messages/ui/attachment/ConversationMessageAttachments.kt index 3d7d5de0..0c26af93 100644 --- a/src/com/android/messaging/ui/conversation/v2/messages/ui/attachment/ConversationMessageAttachments.kt +++ b/src/com/android/messaging/ui/conversation/v2/messages/ui/attachment/ConversationMessageAttachments.kt @@ -16,6 +16,7 @@ internal fun ConversationMessageAttachments( hasTextBelowVisualAttachments: Boolean, onAttachmentClick: (contentType: String, contentUri: String) -> Unit, onExternalUriClick: (String) -> Unit, + onMessageLongClick: () -> Unit, ) { val hasGalleryVisualAttachments = attachmentSections.galleryVisualAttachments.isNotEmpty() val hasTrailingItems = attachmentSections.trailingItems.isNotEmpty() @@ -35,6 +36,7 @@ internal fun ConversationMessageAttachments( hasTextBelowVisualAttachments = hasTextBelowVisualAttachments, onAttachmentClick = onAttachmentClick, onExternalUriClick = onExternalUriClick, + onMessageLongClick = onMessageLongClick, ) } @@ -45,6 +47,7 @@ internal fun ConversationMessageAttachments( attachment = trailingItem.attachment, onAttachmentClick = onAttachmentClick, onExternalUriClick = onExternalUriClick, + onLongClick = onMessageLongClick, ) } @@ -55,6 +58,7 @@ internal fun ConversationMessageAttachments( hasTextBelowVisualAttachments = hasTextBelowVisualAttachments, onAttachmentClick = onAttachmentClick, onExternalUriClick = onExternalUriClick, + onMessageLongClick = onMessageLongClick, ) } } diff --git a/src/com/android/messaging/ui/conversation/v2/messages/ui/attachment/ConversationVisualAttachments.kt b/src/com/android/messaging/ui/conversation/v2/messages/ui/attachment/ConversationVisualAttachments.kt index 50e1568e..54938a4d 100644 --- a/src/com/android/messaging/ui/conversation/v2/messages/ui/attachment/ConversationVisualAttachments.kt +++ b/src/com/android/messaging/ui/conversation/v2/messages/ui/attachment/ConversationVisualAttachments.kt @@ -1,7 +1,7 @@ package com.android.messaging.ui.conversation.v2.messages.ui.attachment import androidx.compose.foundation.background -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.BoxScope @@ -51,6 +51,7 @@ internal fun ConversationGalleryVisualAttachments( hasTextBelowVisualAttachments: Boolean, onAttachmentClick: (contentType: String, contentUri: String) -> Unit, onExternalUriClick: (String) -> Unit, + onMessageLongClick: () -> Unit, ) { when (attachments.size) { 0 -> {} @@ -67,6 +68,7 @@ internal fun ConversationGalleryVisualAttachments( ), onAttachmentClick = onAttachmentClick, onExternalUriClick = onExternalUriClick, + onMessageLongClick = onMessageLongClick, ) } @@ -77,6 +79,7 @@ internal fun ConversationGalleryVisualAttachments( hasTextBelowVisualAttachments = hasTextBelowVisualAttachments, onAttachmentClick = onAttachmentClick, onExternalUriClick = onExternalUriClick, + onMessageLongClick = onMessageLongClick, ) } } @@ -89,6 +92,7 @@ internal fun ConversationStandaloneVisualAttachment( hasTextBelowVisualAttachments: Boolean, onAttachmentClick: (contentType: String, contentUri: String) -> Unit, onExternalUriClick: (String) -> Unit, + onMessageLongClick: () -> Unit, ) { ConversationVisualAttachmentCard( modifier = Modifier.fillMaxWidth(), @@ -102,6 +106,7 @@ internal fun ConversationStandaloneVisualAttachment( ), onAttachmentClick = onAttachmentClick, onExternalUriClick = onExternalUriClick, + onMessageLongClick = onMessageLongClick, ) } @@ -112,6 +117,7 @@ private fun ConversationVisualAttachmentGrid( hasTextBelowVisualAttachments: Boolean, onAttachmentClick: (contentType: String, contentUri: String) -> Unit, onExternalUriClick: (String) -> Unit, + onMessageLongClick: () -> Unit, ) { val attachmentRows = remember(attachments) { attachments.chunked(size = 2) @@ -145,6 +151,7 @@ private fun ConversationVisualAttachmentGrid( ), onAttachmentClick = onAttachmentClick, onExternalUriClick = onExternalUriClick, + onMessageLongClick = onMessageLongClick, ) } } @@ -167,6 +174,7 @@ private fun ConversationVisualAttachmentCard( attachmentShape: RoundedCornerShape, onAttachmentClick: (contentType: String, contentUri: String) -> Unit, onExternalUriClick: (String) -> Unit, + onMessageLongClick: () -> Unit, ) { ConversationVisualAttachmentSurface( modifier = modifier.aspectRatio(ratio = aspectRatio), @@ -175,6 +183,7 @@ private fun ConversationVisualAttachmentCard( contentScale = ContentScale.Crop, onAttachmentClick = onAttachmentClick, onExternalUriClick = onExternalUriClick, + onMessageLongClick = onMessageLongClick, overlay = { if (attachment.requiresPlaybackAffordance()) { CenterPlayAffordance() @@ -191,6 +200,7 @@ private fun ConversationVisualAttachmentSurface( contentScale: ContentScale, onAttachmentClick: (contentType: String, contentUri: String) -> Unit, onExternalUriClick: (String) -> Unit, + onMessageLongClick: () -> Unit, overlay: @Composable BoxScope.() -> Unit, ) { val density = LocalDensity.current @@ -201,8 +211,8 @@ private fun ConversationVisualAttachmentSurface( Surface( modifier = modifier .clip(shape = attachmentShape) - .clickable( - enabled = openAction != null, + .combinedClickable( + enabled = true, onClick = { openAction?.let { action -> dispatchConversationAttachmentOpenAction( @@ -212,6 +222,7 @@ private fun ConversationVisualAttachmentSurface( ) } }, + onLongClick = onMessageLongClick, ), shape = attachmentShape, color = MaterialTheme.colorScheme.surfaceContainerHighest, diff --git a/src/com/android/messaging/ui/conversation/v2/messages/ui/message/ConversationMessage.kt b/src/com/android/messaging/ui/conversation/v2/messages/ui/message/ConversationMessage.kt index e022ebc5..b810b513 100644 --- a/src/com/android/messaging/ui/conversation/v2/messages/ui/message/ConversationMessage.kt +++ b/src/com/android/messaging/ui/conversation/v2/messages/ui/message/ConversationMessage.kt @@ -2,10 +2,15 @@ package com.android.messaging.ui.conversation.v2.messages.ui.message import android.content.Context import android.text.format.DateUtils +import androidx.compose.animation.animateColorAsState +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.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.widthIn @@ -15,15 +20,20 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable +import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.selected +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp @@ -44,13 +54,18 @@ private val MESSAGE_BUBBLE_MEDIA_SECTION_SPACING = 8.dp private val MESSAGE_BUBBLE_MEDIA_TEXT_PADDING = 12.dp private val MESSAGE_BUBBLE_TEXT_HORIZONTAL_PADDING = 16.dp private val MESSAGE_BUBBLE_TEXT_VERTICAL_PADDING = 12.dp +private const val MESSAGE_SELECTION_MEDIA_OVERLAY_ALPHA = 0.2f @Composable internal fun ConversationMessage( modifier: Modifier = Modifier, message: ConversationMessageUiModel, + isSelected: Boolean = false, + isSelectionMode: Boolean = false, onAttachmentClick: (contentType: String, contentUri: String) -> Unit = { _, _ -> }, onExternalUriClick: (String) -> Unit = {}, + onMessageClick: () -> Unit = {}, + onMessageLongClick: () -> Unit = {}, ) { BoxWithConstraints( modifier = modifier @@ -68,10 +83,14 @@ internal fun ConversationMessage( ) { ConversationMessageContent( message = message, + isSelected = isSelected, + isSelectionMode = isSelectionMode, layout = layout, maxBubbleWidth = maxBubbleWidth, onAttachmentClick = onAttachmentClick, onExternalUriClick = onExternalUriClick, + onMessageClick = onMessageClick, + onMessageLongClick = onMessageLongClick, ) } } @@ -199,22 +218,68 @@ private fun messageHorizontalArrangement( @Composable private fun ConversationMessageContent( message: ConversationMessageUiModel, + isSelected: Boolean, + isSelectionMode: Boolean, layout: ConversationMessageLayout, maxBubbleWidth: Dp, onAttachmentClick: (contentType: String, contentUri: String) -> Unit, onExternalUriClick: (String) -> Unit, + onMessageClick: () -> Unit, + onMessageLongClick: () -> Unit, ) { + val hapticFeedback = LocalHapticFeedback.current + val bubbleInteractionModifier = Modifier + .clip(shape = layout.bubbleShape) + .semantics { + selected = isSelected + } + .combinedClickable( + enabled = true, + onClick = { + if (isSelectionMode) { + hapticFeedback.performHapticFeedback(HapticFeedbackType.ContextClick) + onMessageClick() + } + }, + onLongClick = { + hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) + onMessageLongClick() + }, + ) + Column( - modifier = Modifier - .widthIn(max = maxBubbleWidth), + modifier = Modifier.widthIn(max = maxBubbleWidth), horizontalAlignment = messageContentHorizontalAlignment(message = message), ) { ConversationMessageBubble( + modifier = bubbleInteractionModifier, message = message, + isSelected = isSelected, layout = layout, maxBubbleWidth = maxBubbleWidth, - onAttachmentClick = onAttachmentClick, - onExternalUriClick = onExternalUriClick, + onAttachmentClick = { contentType, contentUri -> + when { + isSelectionMode -> { + onMessageClick() + } + + else -> { + onAttachmentClick(contentType, contentUri) + } + } + }, + onExternalUriClick = { uri -> + when { + isSelectionMode -> { + onMessageClick() + } + + else -> { + onExternalUriClick(uri) + } + } + }, + onMessageLongClick = onMessageLongClick, ) ConversationMessageMetadata( @@ -226,54 +291,80 @@ private fun ConversationMessageContent( @Composable private fun ConversationMessageBubble( + modifier: Modifier = Modifier, message: ConversationMessageUiModel, + isSelected: Boolean, layout: ConversationMessageLayout, maxBubbleWidth: Dp, onAttachmentClick: (contentType: String, contentUri: String) -> Unit, onExternalUriClick: (String) -> Unit, + onMessageLongClick: () -> Unit, ) { when (layout.bubbleLayoutMode) { ConversationMessageBubbleLayoutMode.AttachmentOnlyWithoutSurface -> { - ConversationMessageAttachmentBubbleContent( + ConversationMessageAttachmentOnlyContainer( modifier = Modifier .widthIn(max = maxBubbleWidth) - .clip(shape = layout.bubbleShape), - content = layout.content, - senderDisplayName = message.senderDisplayName, - showSender = layout.showSender, - onAttachmentClick = onAttachmentClick, - onExternalUriClick = onExternalUriClick, - ) + .then(other = modifier), + bubbleShape = layout.bubbleShape, + message = message, + isSelected = isSelected, + ) { + ConversationMessageAttachmentBubbleContent( + modifier = Modifier + .fillMaxWidth(), + content = layout.content, + message = message, + isSelected = isSelected, + senderDisplayName = message.senderDisplayName, + showSender = layout.showSender, + onAttachmentClick = onAttachmentClick, + onExternalUriClick = onExternalUriClick, + onMessageLongClick = onMessageLongClick, + ) + } } ConversationMessageBubbleLayoutMode.AttachmentsInSurface -> { ConversationMessageBubbleSurface( + modifier = Modifier + .widthIn(max = maxBubbleWidth) + .then(other = modifier), + isSelected = isSelected, message = message, layout = layout, - maxBubbleWidth = maxBubbleWidth, ) { ConversationMessageAttachmentBubbleContent( content = layout.content, + message = message, + isSelected = isSelected, senderDisplayName = message.senderDisplayName, showSender = layout.showSender, onAttachmentClick = onAttachmentClick, onExternalUriClick = onExternalUriClick, + onMessageLongClick = onMessageLongClick, ) } } ConversationMessageBubbleLayoutMode.TextInSurface -> { ConversationMessageBubbleSurface( + modifier = Modifier + .widthIn(max = maxBubbleWidth) + .then(other = modifier), + isSelected = isSelected, message = message, layout = layout, - maxBubbleWidth = maxBubbleWidth, ) { ConversationMessageTextBubbleContent( content = layout.content, + message = message, + isSelected = isSelected, senderDisplayName = message.senderDisplayName, showSender = layout.showSender, onAttachmentClick = onAttachmentClick, onExternalUriClick = onExternalUriClick, + onMessageLongClick = onMessageLongClick, ) } } @@ -282,28 +373,76 @@ private fun ConversationMessageBubble( @Composable private fun ConversationMessageBubbleSurface( + modifier: Modifier = Modifier, + isSelected: Boolean, message: ConversationMessageUiModel, layout: ConversationMessageLayout, - maxBubbleWidth: Dp, bubbleContent: @Composable () -> Unit, ) { Surface( - color = messageBubbleColor(message = message), - contentColor = messageBubbleContentColor(message = message), + color = messageBubbleColor( + message = message, + isSelected = isSelected, + ), + contentColor = messageBubbleContentColor( + message = message, + isSelected = isSelected, + ), shape = layout.bubbleShape, - modifier = Modifier.widthIn(max = maxBubbleWidth), + modifier = modifier, ) { bubbleContent() } } +@Composable +private fun ConversationMessageAttachmentOnlyContainer( + modifier: Modifier = Modifier, + bubbleShape: RoundedCornerShape, + message: ConversationMessageUiModel, + isSelected: Boolean, + content: @Composable () -> Unit, +) { + val overlayColor by animateColorAsState( + targetValue = when { + isSelected -> { + messageBubbleColor( + message = message, + isSelected = true, + ).copy(alpha = MESSAGE_SELECTION_MEDIA_OVERLAY_ALPHA) + } + + else -> Color.Transparent + }, + label = "conversationMessageSelectionOverlayColor", + ) + + Box( + modifier = modifier.clip(shape = bubbleShape), + ) { + content() + + if (overlayColor != Color.Transparent) { + Box( + modifier = Modifier + .fillMaxSize() + .clip(shape = bubbleShape) + .background(color = overlayColor), + ) + } + } +} + @Composable private fun ConversationMessageTextBubbleContent( content: ConversationMessageContent, + message: ConversationMessageUiModel, + isSelected: Boolean, senderDisplayName: String?, showSender: Boolean, onAttachmentClick: (contentType: String, contentUri: String) -> Unit, onExternalUriClick: (String) -> Unit, + onMessageLongClick: () -> Unit, ) { Column( modifier = Modifier.padding( @@ -313,6 +452,10 @@ private fun ConversationMessageTextBubbleContent( verticalArrangement = Arrangement.spacedBy(space = 8.dp), ) { ConversationMessageSender( + color = messageSenderColor( + message = message, + isSelected = isSelected, + ), senderDisplayName = senderDisplayName, showSender = showSender, ) @@ -321,6 +464,7 @@ private fun ConversationMessageTextBubbleContent( content = content, onAttachmentClick = onAttachmentClick, onExternalUriClick = onExternalUriClick, + onMessageLongClick = onMessageLongClick, ) } } @@ -329,10 +473,13 @@ private fun ConversationMessageTextBubbleContent( private fun ConversationMessageAttachmentBubbleContent( modifier: Modifier = Modifier, content: ConversationMessageContent, + message: ConversationMessageUiModel, + isSelected: Boolean, senderDisplayName: String?, showSender: Boolean, onAttachmentClick: (contentType: String, contentUri: String) -> Unit, onExternalUriClick: (String) -> Unit, + onMessageLongClick: () -> Unit, ) { val hasHeader = showSender || !content.subjectText.isNullOrBlank() val hasBodyText = !content.bodyText.isNullOrBlank() @@ -350,6 +497,10 @@ private fun ConversationMessageAttachmentBubbleContent( else -> MESSAGE_BUBBLE_MEDIA_SECTION_SPACING }, ), + color = messageSenderColor( + message = message, + isSelected = isSelected, + ), senderDisplayName = senderDisplayName, showSender = showSender, ) @@ -372,6 +523,7 @@ private fun ConversationMessageAttachmentBubbleContent( hasTextBelowVisualAttachments = hasBodyText, onAttachmentClick = onAttachmentClick, onExternalUriClick = onExternalUriClick, + onMessageLongClick = onMessageLongClick, ) content.bodyText?.let { bodyText -> @@ -395,6 +547,7 @@ private fun ConversationMessageBody( content: ConversationMessageContent, onAttachmentClick: (contentType: String, contentUri: String) -> Unit, onExternalUriClick: (String) -> Unit, + onMessageLongClick: () -> Unit, ) { content.subjectText?.let { subjectText -> Text( @@ -409,6 +562,7 @@ private fun ConversationMessageBody( hasTextBelowVisualAttachments = false, onAttachmentClick = onAttachmentClick, onExternalUriClick = onExternalUriClick, + onMessageLongClick = onMessageLongClick, ) content.bodyText?.let { bodyText -> @@ -423,6 +577,7 @@ private fun ConversationMessageBody( @Composable private fun ConversationMessageSender( modifier: Modifier = Modifier, + color: Color, senderDisplayName: String?, showSender: Boolean, ) { @@ -434,7 +589,7 @@ private fun ConversationMessageSender( modifier = modifier, text = senderDisplayName, style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.primary, + color = color, maxLines = 1, overflow = TextOverflow.Ellipsis, ) @@ -477,21 +632,53 @@ private fun messageMetadataTextAlign(message: ConversationMessageUiModel): TextA } @Composable -private fun messageBubbleColor(message: ConversationMessageUiModel): Color { +private fun messageBubbleColor( + message: ConversationMessageUiModel, + isSelected: Boolean, +): Color { return when { + isSelected -> MaterialTheme.colorScheme.primary message.isIncoming -> MaterialTheme.colorScheme.surfaceContainerHigh else -> MaterialTheme.colorScheme.primaryContainer } } @Composable -private fun messageBubbleContentColor(message: ConversationMessageUiModel): Color { +private fun messageBubbleContentColor( + message: ConversationMessageUiModel, + isSelected: Boolean, +): Color { return when { + isSelected -> MaterialTheme.colorScheme.onPrimary message.isIncoming -> MaterialTheme.colorScheme.onSurface else -> MaterialTheme.colorScheme.onPrimaryContainer } } +@Composable +private fun messageSenderColor( + message: ConversationMessageUiModel, + isSelected: Boolean, +): Color { + return when { + isSelected -> { + messageBubbleContentColor( + message = message, + isSelected = true, + ) + } + + message.isIncoming -> MaterialTheme.colorScheme.primary + + else -> { + messageBubbleContentColor( + message = message, + isSelected = false, + ) + } + } +} + private fun messageBubbleShape(message: ConversationMessageUiModel): RoundedCornerShape { val cornerRadius = MESSAGE_BUBBLE_CORNER_RADIUS_DP.dp diff --git a/src/com/android/messaging/ui/conversation/v2/screen/ConversationScreen.kt b/src/com/android/messaging/ui/conversation/v2/screen/ConversationScreen.kt index b92258b8..989495cf 100644 --- a/src/com/android/messaging/ui/conversation/v2/screen/ConversationScreen.kt +++ b/src/com/android/messaging/ui/conversation/v2/screen/ConversationScreen.kt @@ -1,12 +1,16 @@ package com.android.messaging.ui.conversation.v2.screen +import androidx.activity.compose.BackHandler import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.material3.AlertDialog import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -22,10 +26,13 @@ import androidx.compose.ui.geometry.Rect as ComposeRect import androidx.compose.ui.layout.boundsInWindow import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.Lifecycle import androidx.lifecycle.compose.LifecycleEventEffect import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.android.messaging.R import com.android.messaging.data.conversation.model.draft.ConversationDraft import com.android.messaging.ui.conversation.v2.CONVERSATION_LOADING_INDICATOR_TEST_TAG import com.android.messaging.ui.conversation.v2.composer.model.ConversationComposerAttachmentUiState @@ -37,6 +44,8 @@ import com.android.messaging.ui.conversation.v2.messages.model.message.Conversat import com.android.messaging.ui.conversation.v2.messages.model.message.ConversationMessagesUiState import com.android.messaging.ui.conversation.v2.messages.ui.ConversationMessages import com.android.messaging.ui.conversation.v2.metadata.ui.ConversationTopAppBar +import com.android.messaging.ui.conversation.v2.screen.model.ConversationMessageDeleteConfirmationUiState +import com.android.messaging.ui.conversation.v2.screen.model.ConversationMessageSelectionAction import com.android.messaging.ui.conversation.v2.screen.model.ConversationScreenScaffoldUiState import kotlinx.collections.immutable.ImmutableList @@ -111,6 +120,10 @@ internal fun ConversationScreen( screenModel.persistDraft() } + BackHandler(enabled = scaffoldUiState.selection.isSelectionMode) { + screenModel.dismissMessageSelection() + } + ConversationScreenEffects( screenModel = screenModel, hostBoundsState = hostBoundsState, @@ -133,6 +146,12 @@ internal fun ConversationScreen( onAddPeopleClick = onAddPeopleClick, onConversationDetailsClick = onConversationDetailsClick, onNavigateBack = onNavigateBack, + onDeleteSelectedMessagesConfirmed = screenModel::confirmDeleteSelectedMessages, + onDeleteSelectedMessagesDismissed = screenModel::dismissDeleteMessageConfirmation, + onDismissMessageSelection = screenModel::dismissMessageSelection, + onMessageClick = screenModel::onMessageClick, + onMessageLongClick = screenModel::onMessageLongClick, + onMessageSelectionActionClick = screenModel::onMessageSelectionActionClick, onOpenMediaPicker = mediaPickerState::open, onMessageTextChange = screenModel::onMessageTextChanged, onPendingAttachmentRemove = screenModel::onRemovePendingAttachment, @@ -172,6 +191,12 @@ private fun ConversationScreenScaffold( messageFieldFocusRequester: FocusRequester, onAddPeopleClick: () -> Unit, onConversationDetailsClick: () -> Unit, + onDeleteSelectedMessagesConfirmed: () -> Unit, + onDeleteSelectedMessagesDismissed: () -> Unit, + onDismissMessageSelection: () -> Unit, + onMessageClick: (String) -> Unit, + onMessageLongClick: (String) -> Unit, + onMessageSelectionActionClick: (ConversationMessageSelectionAction) -> Unit, onNavigateBack: () -> Unit, onOpenMediaPicker: () -> Unit, onMessageTextChange: (String) -> Unit, @@ -185,13 +210,25 @@ private fun ConversationScreenScaffold( Scaffold( modifier = modifier, topBar = { - ConversationTopAppBar( - metadata = uiState.metadata, - isAddPeopleVisible = uiState.canAddPeople, - onAddPeopleClick = onAddPeopleClick, - onTitleClick = onConversationDetailsClick, - onNavigateBack = onNavigateBack, - ) + when { + uiState.selection.isSelectionMode -> { + ConversationSelectionTopAppBar( + selection = uiState.selection, + onActionClick = onMessageSelectionActionClick, + onDismissSelection = onDismissMessageSelection, + ) + } + + else -> { + ConversationTopAppBar( + metadata = uiState.metadata, + isAddPeopleVisible = uiState.canAddPeople, + onAddPeopleClick = onAddPeopleClick, + onTitleClick = onConversationDetailsClick, + onNavigateBack = onNavigateBack, + ) + } + } }, bottomBar = { if (!isMediaPickerOpen) { @@ -219,6 +256,16 @@ private fun ConversationScreenScaffold( contentPadding = contentPadding, onAttachmentClick = onAttachmentClick, onExternalUriClick = onExternalUriClick, + onMessageClick = onMessageClick, + onMessageLongClick = onMessageLongClick, + ) + } + + uiState.selection.deleteConfirmation?.let { deleteConfirmation -> + ConversationDeleteMessagesDialog( + deleteConfirmation = deleteConfirmation, + onConfirm = onDeleteSelectedMessagesConfirmed, + onDismiss = onDeleteSelectedMessagesDismissed, ) } } @@ -231,6 +278,8 @@ private fun ConversationScreenContent( contentPadding: PaddingValues, onAttachmentClick: (contentType: String, contentUri: String) -> Unit, onExternalUriClick: (String) -> Unit, + onMessageClick: (String) -> Unit, + onMessageLongClick: (String) -> Unit, ) { when (val messagesState = uiState.messages) { is ConversationMessagesUiState.Loading -> { @@ -261,13 +310,59 @@ private fun ConversationScreenContent( modifier = modifier.padding(paddingValues = contentPadding), messages = messagesState.messages, listState = messagesListState, + selectedMessageIds = uiState.selection.selectedMessageIds, onAttachmentClick = onAttachmentClick, onExternalUriClick = onExternalUriClick, + onMessageClick = onMessageClick, + onMessageLongClick = onMessageLongClick, ) } } } +@Composable +private fun ConversationDeleteMessagesDialog( + deleteConfirmation: ConversationMessageDeleteConfirmationUiState, + onConfirm: () -> Unit, + onDismiss: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { + Text( + text = pluralStringResource( + id = R.plurals.delete_messages_confirmation_dialog_title, + count = deleteConfirmation.messageIds.size, + deleteConfirmation.messageIds.size, + ), + ) + }, + text = { + Text( + text = stringResource(R.string.delete_message_confirmation_dialog_text), + ) + }, + confirmButton = { + TextButton( + onClick = onConfirm, + ) { + Text( + text = stringResource(R.string.delete_message_confirmation_button), + ) + } + }, + dismissButton = { + TextButton( + onClick = onDismiss, + ) { + Text( + text = stringResource(android.R.string.cancel), + ) + } + }, + ) +} + @Composable private fun AutoScrollToLatestMessage( conversationId: String?, diff --git a/src/com/android/messaging/ui/conversation/v2/screen/ConversationSelectionTopAppBar.kt b/src/com/android/messaging/ui/conversation/v2/screen/ConversationSelectionTopAppBar.kt index 30502e98..63114790 100644 --- a/src/com/android/messaging/ui/conversation/v2/screen/ConversationSelectionTopAppBar.kt +++ b/src/com/android/messaging/ui/conversation/v2/screen/ConversationSelectionTopAppBar.kt @@ -42,14 +42,17 @@ internal fun ConversationSelectionTopAppBar( var isOverflowExpanded by remember { mutableStateOf(value = false) } + val overflowActions = remember(selection.availableActions) { buildList { if (selection.availableActions.contains(ConversationMessageSelectionAction.Share)) { add(ConversationMessageSelectionAction.Share) } + if (selection.availableActions.contains(ConversationMessageSelectionAction.Forward)) { add(ConversationMessageSelectionAction.Forward) } + if (selection.availableActions.contains(ConversationMessageSelectionAction.Details)) { add(ConversationMessageSelectionAction.Details) } @@ -198,25 +201,25 @@ private fun selectionActionLabel( ): String { return when (action) { ConversationMessageSelectionAction.Copy -> { - stringResource(id = R.string.message_context_menu_copy_text) + stringResource(R.string.message_context_menu_copy_text) } ConversationMessageSelectionAction.Delete -> { - stringResource(id = R.string.action_delete_message) + stringResource(R.string.action_delete_message) } ConversationMessageSelectionAction.Details -> { - stringResource(id = R.string.message_context_menu_view_details) + stringResource(R.string.message_context_menu_view_details) } ConversationMessageSelectionAction.Download -> { - stringResource(id = R.string.action_download) + stringResource(R.string.action_download) } ConversationMessageSelectionAction.Forward -> { - stringResource(id = R.string.message_context_menu_forward_message) + stringResource(R.string.message_context_menu_forward_message) } ConversationMessageSelectionAction.Resend -> { - stringResource(id = R.string.action_send) + stringResource(R.string.action_send) } ConversationMessageSelectionAction.Share -> { - stringResource(id = R.string.action_share) + stringResource(R.string.action_share) } } } From edd30748d5b4b4987a916c54b78988513dee81f1 Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Fri, 17 Apr 2026 23:05:04 +0300 Subject: [PATCH 42/99] Simplify nullable scope launch in recipient picker --- .../v2/recipientpicker/delegate/RecipientPickerDelegate.kt | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/com/android/messaging/ui/conversation/v2/recipientpicker/delegate/RecipientPickerDelegate.kt b/src/com/android/messaging/ui/conversation/v2/recipientpicker/delegate/RecipientPickerDelegate.kt index 155cae55..5a330420 100644 --- a/src/com/android/messaging/ui/conversation/v2/recipientpicker/delegate/RecipientPickerDelegate.kt +++ b/src/com/android/messaging/ui/conversation/v2/recipientpicker/delegate/RecipientPickerDelegate.kt @@ -94,9 +94,7 @@ internal class RecipientPickerDelegateImpl @Inject constructor( } override fun onLoadMore() { - val scope = boundScope ?: return - - scope.launch(defaultDispatcher) { + boundScope?.launch(defaultDispatcher) { val loadMoreRequest = createLoadMoreRequest() ?: return@launch loadMore(request = loadMoreRequest) } From 31aa882e131f7df2951e257eecd9d8a449fc5d41 Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Mon, 20 Apr 2026 15:55:40 +0300 Subject: [PATCH 43/99] Add calls from the conversation screen --- res/values/strings.xml | 2 + .../model/metadata/ConversationMetadata.kt | 1 + .../repository/ConversationsRepository.kt | 5 ++ .../conversation/ConversationBindsModule.kt | 8 +++ .../usecase/IsDeviceVoiceCapable.kt | 15 ++++ .../conversation/v2/ConversationTestTags.kt | 5 +- .../ConversationMetadataUiStateMapper.kt | 4 ++ .../model/ConversationMetadataUiState.kt | 1 + .../v2/metadata/ui/ConversationTopAppBar.kt | 71 +++++++++++++++++-- .../v2/screen/ConversationScreen.kt | 4 ++ .../v2/screen/ConversationScreenEffects.kt | 19 +++++ .../v2/screen/ConversationViewModel.kt | 34 +++++++++ .../screen/model/ConversationScreenEffect.kt | 4 ++ .../ConversationScreenScaffoldUiState.kt | 1 + 14 files changed, 166 insertions(+), 8 deletions(-) create mode 100644 src/com/android/messaging/domain/conversation/usecase/IsDeviceVoiceCapable.kt diff --git a/res/values/strings.xml b/res/values/strings.xml index b2e32cca..40b737e7 100644 --- a/res/values/strings.xml +++ b/res/values/strings.xml @@ -589,6 +589,8 @@ People in this conversation Make a call + + More options Send message diff --git a/src/com/android/messaging/data/conversation/model/metadata/ConversationMetadata.kt b/src/com/android/messaging/data/conversation/model/metadata/ConversationMetadata.kt index 0b0c011b..6124c1d3 100644 --- a/src/com/android/messaging/data/conversation/model/metadata/ConversationMetadata.kt +++ b/src/com/android/messaging/data/conversation/model/metadata/ConversationMetadata.kt @@ -5,5 +5,6 @@ internal data class ConversationMetadata( val selfParticipantId: String, val isGroupConversation: Boolean, val participantCount: Int, + val otherParticipantNormalizedDestination: String?, val composerAvailability: ConversationComposerAvailability, ) diff --git a/src/com/android/messaging/data/conversation/repository/ConversationsRepository.kt b/src/com/android/messaging/data/conversation/repository/ConversationsRepository.kt index c3fdb74f..fe9e12df 100644 --- a/src/com/android/messaging/data/conversation/repository/ConversationsRepository.kt +++ b/src/com/android/messaging/data/conversation/repository/ConversationsRepository.kt @@ -193,6 +193,11 @@ internal class ConversationsRepositoryImpl @Inject constructor( ), isGroupConversation = cursor.getInt(ConversationColumns.PARTICIPANT_COUNT) > 1, participantCount = cursor.getInt(ConversationColumns.PARTICIPANT_COUNT), + otherParticipantNormalizedDestination = cursor + .getStringOrEmpty( + ConversationColumns.OTHER_PARTICIPANT_NORMALIZED_DESTINATION, + ) + .takeIf { it.isNotBlank() }, composerAvailability = ConversationComposerAvailability.editable(), ) } diff --git a/src/com/android/messaging/di/conversation/ConversationBindsModule.kt b/src/com/android/messaging/di/conversation/ConversationBindsModule.kt index b1b1fac0..3125749b 100644 --- a/src/com/android/messaging/di/conversation/ConversationBindsModule.kt +++ b/src/com/android/messaging/di/conversation/ConversationBindsModule.kt @@ -28,6 +28,8 @@ import com.android.messaging.domain.conversation.usecase.ForwardedMessageSubject import com.android.messaging.domain.conversation.usecase.ForwardedMessageSubjectFormatterImpl import com.android.messaging.domain.conversation.usecase.IsConversationRecipientLimitExceeded import com.android.messaging.domain.conversation.usecase.IsConversationRecipientLimitExceededImpl +import com.android.messaging.domain.conversation.usecase.IsDeviceVoiceCapable +import com.android.messaging.domain.conversation.usecase.IsDeviceVoiceCapableImpl import com.android.messaging.domain.conversation.usecase.ResolveConversationId import com.android.messaging.domain.conversation.usecase.ResolveConversationIdImpl import com.android.messaging.domain.conversation.usecase.SendConversationDraft @@ -98,6 +100,12 @@ internal abstract class ConversationBindsModule { impl: CanAddMoreConversationParticipantsImpl, ): CanAddMoreConversationParticipants + @Binds + @Reusable + abstract fun bindIsDeviceVoiceCapable( + impl: IsDeviceVoiceCapableImpl, + ): IsDeviceVoiceCapable + @Binds @Reusable abstract fun bindCreateForwardedMessage( diff --git a/src/com/android/messaging/domain/conversation/usecase/IsDeviceVoiceCapable.kt b/src/com/android/messaging/domain/conversation/usecase/IsDeviceVoiceCapable.kt new file mode 100644 index 00000000..4e224f34 --- /dev/null +++ b/src/com/android/messaging/domain/conversation/usecase/IsDeviceVoiceCapable.kt @@ -0,0 +1,15 @@ +package com.android.messaging.domain.conversation.usecase + +import com.android.messaging.util.PhoneUtils +import javax.inject.Inject + +internal fun interface IsDeviceVoiceCapable { + operator fun invoke(): Boolean +} + +internal class IsDeviceVoiceCapableImpl @Inject constructor() : IsDeviceVoiceCapable { + + override operator fun invoke(): Boolean { + return PhoneUtils.getDefault().isVoiceCapable + } +} diff --git a/src/com/android/messaging/ui/conversation/v2/ConversationTestTags.kt b/src/com/android/messaging/ui/conversation/v2/ConversationTestTags.kt index f7f18f09..9e0214d3 100644 --- a/src/com/android/messaging/ui/conversation/v2/ConversationTestTags.kt +++ b/src/com/android/messaging/ui/conversation/v2/ConversationTestTags.kt @@ -7,8 +7,9 @@ internal const val CONVERSATION_COMPOSE_BAR_TEST_TAG = "conversation_compose_bar internal const val CONVERSATION_ATTACHMENT_BUTTON_TEST_TAG = "conversation_attachment_button" internal const val CONVERSATION_ATTACHMENT_PREVIEW_LIST_TEST_TAG = "conversation_attachment_preview_list" -internal const val CONVERSATION_ADD_PEOPLE_BUTTON_TEST_TAG = - "conversation_add_people_button" +internal const val CONVERSATION_ADD_PEOPLE_BUTTON_TEST_TAG = "conversation_add_people_button" +internal const val CONVERSATION_CALL_BUTTON_TEST_TAG = "conversation_call_button" +internal const val CONVERSATION_OVERFLOW_BUTTON_TEST_TAG = "conversation_overflow_button" internal const val CONVERSATION_LOADING_INDICATOR_TEST_TAG = "conversation_loading_indicator" internal const val CONVERSATION_MESSAGES_LIST_TEST_TAG = "conversation_messages_list" internal const val CONVERSATION_MEDIA_PICKER_OVERLAY_TEST_TAG = "conversation_media_picker_overlay" diff --git a/src/com/android/messaging/ui/conversation/v2/metadata/mapper/ConversationMetadataUiStateMapper.kt b/src/com/android/messaging/ui/conversation/v2/metadata/mapper/ConversationMetadataUiStateMapper.kt index aede8f98..25f96d8f 100644 --- a/src/com/android/messaging/ui/conversation/v2/metadata/mapper/ConversationMetadataUiStateMapper.kt +++ b/src/com/android/messaging/ui/conversation/v2/metadata/mapper/ConversationMetadataUiStateMapper.kt @@ -1,6 +1,7 @@ package com.android.messaging.ui.conversation.v2.metadata.mapper import com.android.messaging.data.conversation.model.metadata.ConversationMetadata +import com.android.messaging.sms.MmsSmsUtils import com.android.messaging.ui.conversation.v2.metadata.model.ConversationMetadataUiState import javax.inject.Inject @@ -17,6 +18,9 @@ internal class ConversationMetadataUiStateMapperImpl @Inject constructor() : selfParticipantId = metadata.selfParticipantId, isGroupConversation = metadata.isGroupConversation, participantCount = metadata.participantCount, + otherParticipantPhoneNumber = metadata + .otherParticipantNormalizedDestination + ?.takeIf(MmsSmsUtils::isPhoneNumber), composerAvailability = metadata.composerAvailability, ) } diff --git a/src/com/android/messaging/ui/conversation/v2/metadata/model/ConversationMetadataUiState.kt b/src/com/android/messaging/ui/conversation/v2/metadata/model/ConversationMetadataUiState.kt index be116c53..ef7acfc6 100644 --- a/src/com/android/messaging/ui/conversation/v2/metadata/model/ConversationMetadataUiState.kt +++ b/src/com/android/messaging/ui/conversation/v2/metadata/model/ConversationMetadataUiState.kt @@ -21,6 +21,7 @@ internal sealed interface ConversationMetadataUiState { val selfParticipantId: String, val isGroupConversation: Boolean, val participantCount: Int, + val otherParticipantPhoneNumber: String?, override val composerAvailability: ConversationComposerAvailability, ) : ConversationMetadataUiState diff --git a/src/com/android/messaging/ui/conversation/v2/metadata/ui/ConversationTopAppBar.kt b/src/com/android/messaging/ui/conversation/v2/metadata/ui/ConversationTopAppBar.kt index 9218ee3a..ff436042 100644 --- a/src/com/android/messaging/ui/conversation/v2/metadata/ui/ConversationTopAppBar.kt +++ b/src/com/android/messaging/ui/conversation/v2/metadata/ui/ConversationTopAppBar.kt @@ -11,9 +11,13 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.ArrowBack +import androidx.compose.material.icons.rounded.Call import androidx.compose.material.icons.rounded.Group import androidx.compose.material.icons.rounded.GroupAdd +import androidx.compose.material.icons.rounded.MoreVert import androidx.compose.material.icons.rounded.Person +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -25,7 +29,10 @@ import androidx.compose.material3.TopAppBarColors import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable +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.platform.testTag @@ -36,6 +43,8 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.android.messaging.R import com.android.messaging.ui.conversation.v2.CONVERSATION_ADD_PEOPLE_BUTTON_TEST_TAG +import com.android.messaging.ui.conversation.v2.CONVERSATION_CALL_BUTTON_TEST_TAG +import com.android.messaging.ui.conversation.v2.CONVERSATION_OVERFLOW_BUTTON_TEST_TAG import com.android.messaging.ui.conversation.v2.metadata.model.ConversationMetadataUiState private val CONVERSATION_TOP_APP_BAR_TITLE_SPACING = 12.dp @@ -48,7 +57,9 @@ internal fun ConversationTopAppBar( modifier: Modifier = Modifier, metadata: ConversationMetadataUiState, isAddPeopleVisible: Boolean = false, + isCallVisible: Boolean = false, onAddPeopleClick: () -> Unit, + onCallClick: () -> Unit = {}, onTitleClick: () -> Unit, onNavigateBack: () -> Unit, ) { @@ -73,8 +84,13 @@ internal fun ConversationTopAppBar( ) }, actions = { + if (isCallVisible) { + ConversationTopAppBarCallAction( + onCallClick = onCallClick, + ) + } if (isAddPeopleVisible) { - ConversationTopAppBarAddPeopleAction( + ConversationTopAppBarOverflowMenu( onAddPeopleClick = onAddPeopleClick, ) } @@ -189,16 +205,59 @@ private fun ConversationTopAppBarNavigationIcon( } @Composable -private fun ConversationTopAppBarAddPeopleAction( +private fun ConversationTopAppBarCallAction( + onCallClick: () -> Unit, +) { + IconButton( + modifier = Modifier.testTag(CONVERSATION_CALL_BUTTON_TEST_TAG), + onClick = onCallClick, + ) { + Icon( + imageVector = Icons.Rounded.Call, + contentDescription = stringResource(id = R.string.action_call), + ) + } +} + +@Composable +private fun ConversationTopAppBarOverflowMenu( onAddPeopleClick: () -> Unit, ) { + var isExpanded by remember { mutableStateOf(value = false) } + IconButton( - modifier = Modifier.testTag(CONVERSATION_ADD_PEOPLE_BUTTON_TEST_TAG), - onClick = onAddPeopleClick, + modifier = Modifier.testTag(CONVERSATION_OVERFLOW_BUTTON_TEST_TAG), + onClick = { isExpanded = true }, ) { Icon( - imageVector = Icons.Rounded.GroupAdd, - contentDescription = stringResource(id = R.string.conversation_add_people), + imageVector = Icons.Rounded.MoreVert, + contentDescription = stringResource(id = R.string.action_more_options), + ) + } + + DropdownMenu( + expanded = isExpanded, + onDismissRequest = { + @Suppress("AssignedValueIsNeverRead") + isExpanded = false + }, + ) { + DropdownMenuItem( + modifier = Modifier.testTag(CONVERSATION_ADD_PEOPLE_BUTTON_TEST_TAG), + text = { + Text(text = stringResource(id = R.string.conversation_add_people)) + }, + leadingIcon = { + Icon( + imageVector = Icons.Rounded.GroupAdd, + contentDescription = null, + ) + }, + onClick = { + @Suppress("AssignedValueIsNeverRead") + isExpanded = false + onAddPeopleClick() + }, ) } } diff --git a/src/com/android/messaging/ui/conversation/v2/screen/ConversationScreen.kt b/src/com/android/messaging/ui/conversation/v2/screen/ConversationScreen.kt index 989495cf..ab40d137 100644 --- a/src/com/android/messaging/ui/conversation/v2/screen/ConversationScreen.kt +++ b/src/com/android/messaging/ui/conversation/v2/screen/ConversationScreen.kt @@ -144,6 +144,7 @@ internal fun ConversationScreen( isMediaPickerOpen = mediaPickerState.isOpen, messageFieldFocusRequester = messageFieldFocusRequester, onAddPeopleClick = onAddPeopleClick, + onCallClick = screenModel::onCallClick, onConversationDetailsClick = onConversationDetailsClick, onNavigateBack = onNavigateBack, onDeleteSelectedMessagesConfirmed = screenModel::confirmDeleteSelectedMessages, @@ -190,6 +191,7 @@ private fun ConversationScreenScaffold( isMediaPickerOpen: Boolean, messageFieldFocusRequester: FocusRequester, onAddPeopleClick: () -> Unit, + onCallClick: () -> Unit, onConversationDetailsClick: () -> Unit, onDeleteSelectedMessagesConfirmed: () -> Unit, onDeleteSelectedMessagesDismissed: () -> Unit, @@ -223,7 +225,9 @@ private fun ConversationScreenScaffold( ConversationTopAppBar( metadata = uiState.metadata, isAddPeopleVisible = uiState.canAddPeople, + isCallVisible = uiState.canCall, onAddPeopleClick = onAddPeopleClick, + onCallClick = onCallClick, onTitleClick = onConversationDetailsClick, onNavigateBack = onNavigateBack, ) diff --git a/src/com/android/messaging/ui/conversation/v2/screen/ConversationScreenEffects.kt b/src/com/android/messaging/ui/conversation/v2/screen/ConversationScreenEffects.kt index e0c00c00..2a81def1 100644 --- a/src/com/android/messaging/ui/conversation/v2/screen/ConversationScreenEffects.kt +++ b/src/com/android/messaging/ui/conversation/v2/screen/ConversationScreenEffects.kt @@ -3,6 +3,7 @@ package com.android.messaging.ui.conversation.v2.screen import android.content.ContentResolver import android.content.Context import android.content.Intent +import android.graphics.Point import android.graphics.Rect import android.net.Uri import androidx.compose.runtime.Composable @@ -57,6 +58,13 @@ internal fun ConversationScreenEffects( ) } + is ConversationScreenEffect.PlacePhoneCall -> { + placePhoneCall( + context = context, + phoneNumber = effect.phoneNumber, + ) + } + is ConversationScreenEffect.LaunchForwardMessage -> { UIIntents.get().launchForwardMessageActivity( context, @@ -97,6 +105,17 @@ private fun openExternalUri( UIIntents.get().launchBrowserForUrl(context, uri) } +private fun placePhoneCall( + context: Context, + phoneNumber: String, +) { + UIIntents.get().launchPhoneCallActivity( + context, + phoneNumber, + Point(0, 0), + ) +} + private suspend fun openShareSheet( context: Context, attachmentContentType: String?, diff --git a/src/com/android/messaging/ui/conversation/v2/screen/ConversationViewModel.kt b/src/com/android/messaging/ui/conversation/v2/screen/ConversationViewModel.kt index ab7cebe8..bc6ca808 100644 --- a/src/com/android/messaging/ui/conversation/v2/screen/ConversationViewModel.kt +++ b/src/com/android/messaging/ui/conversation/v2/screen/ConversationViewModel.kt @@ -8,6 +8,7 @@ import com.android.messaging.data.media.model.ConversationMediaItem import com.android.messaging.datamodel.MessagingContentProvider import com.android.messaging.di.core.DefaultDispatcher import com.android.messaging.domain.conversation.usecase.CanAddMoreConversationParticipants +import com.android.messaging.domain.conversation.usecase.IsDeviceVoiceCapable import com.android.messaging.ui.conversation.v2.composer.delegate.ConversationDraftDelegate import com.android.messaging.ui.conversation.v2.composer.mapper.ConversationComposerUiStateMapper import com.android.messaging.ui.conversation.v2.composer.model.ConversationComposerAttachmentUiState @@ -63,6 +64,8 @@ internal interface ConversationScreenModel { fun onMessageLongClick(messageId: String) fun onMessageSelectionActionClick(action: ConversationMessageSelectionAction) + fun onCallClick() + fun onExternalUriClicked(uri: String) fun onGalleryMediaConfirmed(mediaItems: List) @@ -92,6 +95,7 @@ internal class ConversationViewModel @Inject constructor( private val conversationMetadataDelegate: ConversationMetadataDelegate, private val conversationComposerUiStateMapper: ConversationComposerUiStateMapper, private val canAddMoreConversationParticipants: CanAddMoreConversationParticipants, + private val isDeviceVoiceCapable: IsDeviceVoiceCapable, @param:DefaultDispatcher private val defaultDispatcher: CoroutineDispatcher, private val savedStateHandle: SavedStateHandle, @@ -135,6 +139,7 @@ internal class ConversationViewModel @Inject constructor( ) { metadataState, messagesUiState, composerUiState, selectionUiState -> ConversationScreenScaffoldUiState( canAddPeople = canAddPeople(metadataState = metadataState), + canCall = canCall(metadataState = metadataState), metadata = metadataState, messages = messagesUiState, composer = composerUiState, @@ -149,6 +154,9 @@ internal class ConversationViewModel @Inject constructor( canAddPeople = canAddPeople( metadataState = conversationMetadataDelegate.state.value, ), + canCall = canCall( + metadataState = conversationMetadataDelegate.state.value, + ), metadata = conversationMetadataDelegate.state.value, messages = conversationMessagesDelegate.state.value, composer = composerUiState.value, @@ -247,6 +255,15 @@ internal class ConversationViewModel @Inject constructor( } } + private fun canCall( + metadataState: ConversationMetadataUiState, + ): Boolean { + val isOneOnOne = metadataState is ConversationMetadataUiState.Present && + !metadataState.isGroupConversation && + metadataState.otherParticipantPhoneNumber != null + return isOneOnOne && isDeviceVoiceCapable() + } + override fun onSeedDraft( conversationId: String, draft: ConversationDraft, @@ -329,6 +346,23 @@ internal class ConversationViewModel @Inject constructor( conversationMessageSelectionDelegate.onMessageSelectionActionClick(action = action) } + override fun onCallClick() { + val phoneNumber = ( + conversationMetadataDelegate.state.value as? + ConversationMetadataUiState.Present + ) + ?.otherParticipantPhoneNumber + ?: return + + viewModelScope.launch(defaultDispatcher) { + _effects.emit( + ConversationScreenEffect.PlacePhoneCall( + phoneNumber = phoneNumber, + ), + ) + } + } + override fun onExternalUriClicked(uri: String) { viewModelScope.launch(defaultDispatcher) { _effects.emit( diff --git a/src/com/android/messaging/ui/conversation/v2/screen/model/ConversationScreenEffect.kt b/src/com/android/messaging/ui/conversation/v2/screen/model/ConversationScreenEffect.kt index c93ece2c..62fcad0e 100644 --- a/src/com/android/messaging/ui/conversation/v2/screen/model/ConversationScreenEffect.kt +++ b/src/com/android/messaging/ui/conversation/v2/screen/model/ConversationScreenEffect.kt @@ -20,6 +20,10 @@ internal sealed interface ConversationScreenEffect { val uri: String, ) : ConversationScreenEffect + data class PlacePhoneCall( + val phoneNumber: String, + ) : ConversationScreenEffect + data class ShareMessage( val attachmentContentType: String?, val attachmentContentUri: String?, diff --git a/src/com/android/messaging/ui/conversation/v2/screen/model/ConversationScreenScaffoldUiState.kt b/src/com/android/messaging/ui/conversation/v2/screen/model/ConversationScreenScaffoldUiState.kt index 586eb499..83cda0ab 100644 --- a/src/com/android/messaging/ui/conversation/v2/screen/model/ConversationScreenScaffoldUiState.kt +++ b/src/com/android/messaging/ui/conversation/v2/screen/model/ConversationScreenScaffoldUiState.kt @@ -8,6 +8,7 @@ import com.android.messaging.ui.conversation.v2.metadata.model.ConversationMetad @Immutable internal data class ConversationScreenScaffoldUiState( val canAddPeople: Boolean = false, + val canCall: Boolean = false, val metadata: ConversationMetadataUiState = ConversationMetadataUiState.Loading, val messages: ConversationMessagesUiState = ConversationMessagesUiState.Loading, val composer: ConversationComposerUiState = ConversationComposerUiState(), From e5f8ea117718a2ba7cbfcf57351da95adf81098a Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Mon, 20 Apr 2026 18:48:00 +0300 Subject: [PATCH 44/99] Implement more conversation actions --- .../model/metadata/ConversationMetadata.kt | 2 + .../repository/ConversationsRepository.kt | 35 +++++ .../conversation/v2/ConversationTestTags.kt | 5 + .../delegate/ConversationMetadataDelegate.kt | 85 ++++++++++- .../ConversationMetadataUiStateMapper.kt | 2 + .../model/ConversationMetadataUiState.kt | 2 + .../v2/metadata/ui/ConversationTopAppBar.kt | 143 +++++++++++++++--- .../v2/screen/ConversationScreen.kt | 57 +++++++ .../v2/screen/ConversationScreenEffects.kt | 14 +- .../v2/screen/ConversationViewModel.kt | 105 ++++++++++--- .../screen/model/ConversationScreenEffect.kt | 6 + .../ConversationScreenScaffoldUiState.kt | 5 + 12 files changed, 419 insertions(+), 42 deletions(-) diff --git a/src/com/android/messaging/data/conversation/model/metadata/ConversationMetadata.kt b/src/com/android/messaging/data/conversation/model/metadata/ConversationMetadata.kt index 6124c1d3..622ff945 100644 --- a/src/com/android/messaging/data/conversation/model/metadata/ConversationMetadata.kt +++ b/src/com/android/messaging/data/conversation/model/metadata/ConversationMetadata.kt @@ -6,5 +6,7 @@ internal data class ConversationMetadata( val isGroupConversation: Boolean, val participantCount: Int, val otherParticipantNormalizedDestination: String?, + val otherParticipantContactLookupKey: String?, + val isArchived: Boolean, val composerAvailability: ConversationComposerAvailability, ) diff --git a/src/com/android/messaging/data/conversation/repository/ConversationsRepository.kt b/src/com/android/messaging/data/conversation/repository/ConversationsRepository.kt index fe9e12df..ed521f9a 100644 --- a/src/com/android/messaging/data/conversation/repository/ConversationsRepository.kt +++ b/src/com/android/messaging/data/conversation/repository/ConversationsRepository.kt @@ -8,9 +8,11 @@ import com.android.messaging.data.conversation.model.metadata.ConversationMetada import com.android.messaging.datamodel.DatabaseHelper.ConversationColumns import com.android.messaging.datamodel.DatabaseHelper.ParticipantColumns import com.android.messaging.datamodel.MessagingContentProvider +import com.android.messaging.datamodel.action.DeleteConversationAction import com.android.messaging.datamodel.action.DeleteMessageAction import com.android.messaging.datamodel.action.RedownloadMmsAction import com.android.messaging.datamodel.action.ResendMessageAction +import com.android.messaging.datamodel.action.UpdateConversationArchiveStatusAction import com.android.messaging.datamodel.data.ConversationListItemData import com.android.messaging.datamodel.data.ConversationMessageData import com.android.messaging.datamodel.data.ConversationParticipantsData @@ -46,6 +48,12 @@ internal interface ConversationsRepository { ): ConversationMessageDetailsData? fun resendMessage(messageId: String) + + fun archiveConversation(conversationId: String) + + fun unarchiveConversation(conversationId: String) + + fun deleteConversation(conversationId: String) } internal data class ConversationMessageDetailsData( @@ -135,6 +143,29 @@ internal class ConversationsRepositoryImpl @Inject constructor( ?.let(ResendMessageAction::resendMessage) } + override fun archiveConversation(conversationId: String) { + conversationId + .takeIf { it.isNotBlank() } + ?.let(UpdateConversationArchiveStatusAction::archiveConversation) + } + + override fun unarchiveConversation(conversationId: String) { + conversationId + .takeIf { it.isNotBlank() } + ?.let(UpdateConversationArchiveStatusAction::unarchiveConversation) + } + + override fun deleteConversation(conversationId: String) { + if (conversationId.isBlank()) { + return + } + + DeleteConversationAction.deleteConversation( + conversationId, + System.currentTimeMillis(), + ) + } + private fun observeUri(uri: Uri): Flow { return callbackFlow { val observer = object : ContentObserver(null) { @@ -198,6 +229,10 @@ internal class ConversationsRepositoryImpl @Inject constructor( ConversationColumns.OTHER_PARTICIPANT_NORMALIZED_DESTINATION, ) .takeIf { it.isNotBlank() }, + otherParticipantContactLookupKey = cursor + .getStringOrEmpty(ConversationColumns.PARTICIPANT_LOOKUP_KEY) + .takeIf { it.isNotBlank() }, + isArchived = cursor.getInt(ConversationColumns.ARCHIVE_STATUS) == 1, composerAvailability = ConversationComposerAvailability.editable(), ) } diff --git a/src/com/android/messaging/ui/conversation/v2/ConversationTestTags.kt b/src/com/android/messaging/ui/conversation/v2/ConversationTestTags.kt index 9e0214d3..df74fae2 100644 --- a/src/com/android/messaging/ui/conversation/v2/ConversationTestTags.kt +++ b/src/com/android/messaging/ui/conversation/v2/ConversationTestTags.kt @@ -10,6 +10,11 @@ internal const val CONVERSATION_ATTACHMENT_PREVIEW_LIST_TEST_TAG = internal const val CONVERSATION_ADD_PEOPLE_BUTTON_TEST_TAG = "conversation_add_people_button" internal const val CONVERSATION_CALL_BUTTON_TEST_TAG = "conversation_call_button" internal const val CONVERSATION_OVERFLOW_BUTTON_TEST_TAG = "conversation_overflow_button" +internal const val CONVERSATION_ARCHIVE_BUTTON_TEST_TAG = "conversation_archive_button" +internal const val CONVERSATION_UNARCHIVE_BUTTON_TEST_TAG = "conversation_unarchive_button" +internal const val CONVERSATION_ADD_CONTACT_BUTTON_TEST_TAG = "conversation_add_contact_button" +internal const val CONVERSATION_DELETE_CONVERSATION_BUTTON_TEST_TAG = + "conversation_delete_conversation_button" internal const val CONVERSATION_LOADING_INDICATOR_TEST_TAG = "conversation_loading_indicator" internal const val CONVERSATION_MESSAGES_LIST_TEST_TAG = "conversation_messages_list" internal const val CONVERSATION_MEDIA_PICKER_OVERLAY_TEST_TAG = "conversation_media_picker_overlay" diff --git a/src/com/android/messaging/ui/conversation/v2/metadata/delegate/ConversationMetadataDelegate.kt b/src/com/android/messaging/ui/conversation/v2/metadata/delegate/ConversationMetadataDelegate.kt index 72e502ed..05536e13 100644 --- a/src/com/android/messaging/ui/conversation/v2/metadata/delegate/ConversationMetadataDelegate.kt +++ b/src/com/android/messaging/ui/conversation/v2/metadata/delegate/ConversationMetadataDelegate.kt @@ -5,11 +5,15 @@ import com.android.messaging.di.core.DefaultDispatcher import com.android.messaging.ui.conversation.v2.common.ConversationScreenDelegate import com.android.messaging.ui.conversation.v2.metadata.mapper.ConversationMetadataUiStateMapper import com.android.messaging.ui.conversation.v2.metadata.model.ConversationMetadataUiState +import com.android.messaging.ui.conversation.v2.screen.model.ConversationScreenEffect import javax.inject.Inject import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.flowOn @@ -17,7 +21,17 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch internal interface ConversationMetadataDelegate : - ConversationScreenDelegate + ConversationScreenDelegate { + val effects: Flow + val isDeleteConversationConfirmationVisible: StateFlow + + fun onArchiveConversationClick() + fun onUnarchiveConversationClick() + fun onAddContactClick() + fun onDeleteConversationClick() + fun confirmDeleteConversation() + fun dismissDeleteConversationConfirmation() +} internal class ConversationMetadataDelegateImpl @Inject constructor( private val conversationsRepository: ConversationsRepository, @@ -26,26 +40,37 @@ internal class ConversationMetadataDelegateImpl @Inject constructor( private val defaultDispatcher: CoroutineDispatcher, ) : ConversationMetadataDelegate { + private val _effects = MutableSharedFlow( + extraBufferCapacity = 1, + ) private val _state = MutableStateFlow( value = ConversationMetadataUiState.Loading, ) + private val _isDeleteConversationConfirmationVisible = MutableStateFlow(value = false) + + override val effects = _effects.asSharedFlow() override val state = _state.asStateFlow() + override val isDeleteConversationConfirmationVisible = + _isDeleteConversationConfirmationVisible.asStateFlow() - private var isBound = false + private var boundScope: CoroutineScope? = null + private var boundConversationIdFlow: StateFlow? = null override fun bind( scope: CoroutineScope, conversationIdFlow: StateFlow, ) { - if (isBound) { + if (boundScope != null) { return } - isBound = true + boundScope = scope + boundConversationIdFlow = conversationIdFlow scope.launch(defaultDispatcher) { conversationIdFlow.collectLatest { conversationId -> _state.value = ConversationMetadataUiState.Loading + _isDeleteConversationConfirmationVisible.value = false if (conversationId == null) { return@collectLatest @@ -68,4 +93,56 @@ internal class ConversationMetadataDelegateImpl @Inject constructor( } } } + + override fun onArchiveConversationClick() { + boundScope?.launch(defaultDispatcher) { + currentConversationId?.let(conversationsRepository::archiveConversation) + _effects.emit(ConversationScreenEffect.CloseConversation) + } + } + + override fun onUnarchiveConversationClick() { + boundScope?.launch(defaultDispatcher) { + currentConversationId?.let(conversationsRepository::unarchiveConversation) + } + } + + override fun onAddContactClick() { + val destination = (_state.value as? ConversationMetadataUiState.Present) + ?.otherParticipantPhoneNumber + ?.takeIf { it.isNotBlank() } + ?: return + + boundScope?.launch(defaultDispatcher) { + _effects.emit( + ConversationScreenEffect.LaunchAddContactFlow(destination = destination), + ) + } + } + + override fun onDeleteConversationClick() { + currentConversationId?.let { + _isDeleteConversationConfirmationVisible.value = true + } + } + + override fun confirmDeleteConversation() { + _isDeleteConversationConfirmationVisible.value = false + + boundScope?.launch(defaultDispatcher) { + currentConversationId?.let(conversationsRepository::deleteConversation) + _effects.emit(ConversationScreenEffect.CloseConversation) + } + } + + override fun dismissDeleteConversationConfirmation() { + _isDeleteConversationConfirmationVisible.value = false + } + + private val currentConversationId: String? + get() { + return boundConversationIdFlow + ?.value + ?.takeIf { it.isNotBlank() } + } } diff --git a/src/com/android/messaging/ui/conversation/v2/metadata/mapper/ConversationMetadataUiStateMapper.kt b/src/com/android/messaging/ui/conversation/v2/metadata/mapper/ConversationMetadataUiStateMapper.kt index 25f96d8f..a1a04baf 100644 --- a/src/com/android/messaging/ui/conversation/v2/metadata/mapper/ConversationMetadataUiStateMapper.kt +++ b/src/com/android/messaging/ui/conversation/v2/metadata/mapper/ConversationMetadataUiStateMapper.kt @@ -21,6 +21,8 @@ internal class ConversationMetadataUiStateMapperImpl @Inject constructor() : otherParticipantPhoneNumber = metadata .otherParticipantNormalizedDestination ?.takeIf(MmsSmsUtils::isPhoneNumber), + otherParticipantContactLookupKey = metadata.otherParticipantContactLookupKey, + isArchived = metadata.isArchived, composerAvailability = metadata.composerAvailability, ) } diff --git a/src/com/android/messaging/ui/conversation/v2/metadata/model/ConversationMetadataUiState.kt b/src/com/android/messaging/ui/conversation/v2/metadata/model/ConversationMetadataUiState.kt index ef7acfc6..c213f43c 100644 --- a/src/com/android/messaging/ui/conversation/v2/metadata/model/ConversationMetadataUiState.kt +++ b/src/com/android/messaging/ui/conversation/v2/metadata/model/ConversationMetadataUiState.kt @@ -22,6 +22,8 @@ internal sealed interface ConversationMetadataUiState { val isGroupConversation: Boolean, val participantCount: Int, val otherParticipantPhoneNumber: String?, + val otherParticipantContactLookupKey: String?, + val isArchived: Boolean, override val composerAvailability: ConversationComposerAvailability, ) : ConversationMetadataUiState diff --git a/src/com/android/messaging/ui/conversation/v2/metadata/ui/ConversationTopAppBar.kt b/src/com/android/messaging/ui/conversation/v2/metadata/ui/ConversationTopAppBar.kt index ff436042..3aafd02a 100644 --- a/src/com/android/messaging/ui/conversation/v2/metadata/ui/ConversationTopAppBar.kt +++ b/src/com/android/messaging/ui/conversation/v2/metadata/ui/ConversationTopAppBar.kt @@ -11,11 +11,15 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.ArrowBack +import androidx.compose.material.icons.rounded.Archive import androidx.compose.material.icons.rounded.Call +import androidx.compose.material.icons.rounded.Delete import androidx.compose.material.icons.rounded.Group import androidx.compose.material.icons.rounded.GroupAdd import androidx.compose.material.icons.rounded.MoreVert import androidx.compose.material.icons.rounded.Person +import androidx.compose.material.icons.rounded.PersonAdd +import androidx.compose.material.icons.rounded.Unarchive import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api @@ -42,9 +46,13 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.android.messaging.R +import com.android.messaging.ui.conversation.v2.CONVERSATION_ADD_CONTACT_BUTTON_TEST_TAG import com.android.messaging.ui.conversation.v2.CONVERSATION_ADD_PEOPLE_BUTTON_TEST_TAG +import com.android.messaging.ui.conversation.v2.CONVERSATION_ARCHIVE_BUTTON_TEST_TAG import com.android.messaging.ui.conversation.v2.CONVERSATION_CALL_BUTTON_TEST_TAG +import com.android.messaging.ui.conversation.v2.CONVERSATION_DELETE_CONVERSATION_BUTTON_TEST_TAG import com.android.messaging.ui.conversation.v2.CONVERSATION_OVERFLOW_BUTTON_TEST_TAG +import com.android.messaging.ui.conversation.v2.CONVERSATION_UNARCHIVE_BUTTON_TEST_TAG import com.android.messaging.ui.conversation.v2.metadata.model.ConversationMetadataUiState private val CONVERSATION_TOP_APP_BAR_TITLE_SPACING = 12.dp @@ -58,8 +66,16 @@ internal fun ConversationTopAppBar( metadata: ConversationMetadataUiState, isAddPeopleVisible: Boolean = false, isCallVisible: Boolean = false, + isArchiveVisible: Boolean = false, + isUnarchiveVisible: Boolean = false, + isAddContactVisible: Boolean = false, + isDeleteConversationVisible: Boolean = false, onAddPeopleClick: () -> Unit, onCallClick: () -> Unit = {}, + onArchiveClick: () -> Unit = {}, + onUnarchiveClick: () -> Unit = {}, + onAddContactClick: () -> Unit = {}, + onDeleteConversationClick: () -> Unit = {}, onTitleClick: () -> Unit, onNavigateBack: () -> Unit, ) { @@ -89,9 +105,23 @@ internal fun ConversationTopAppBar( onCallClick = onCallClick, ) } - if (isAddPeopleVisible) { + val isOverflowVisible = isAddPeopleVisible || + isArchiveVisible || + isUnarchiveVisible || + isAddContactVisible || + isDeleteConversationVisible + if (isOverflowVisible) { ConversationTopAppBarOverflowMenu( + isAddPeopleVisible = isAddPeopleVisible, + isArchiveVisible = isArchiveVisible, + isUnarchiveVisible = isUnarchiveVisible, + isAddContactVisible = isAddContactVisible, + isDeleteConversationVisible = isDeleteConversationVisible, onAddPeopleClick = onAddPeopleClick, + onArchiveClick = onArchiveClick, + onUnarchiveClick = onUnarchiveClick, + onAddContactClick = onAddContactClick, + onDeleteConversationClick = onDeleteConversationClick, ) } }, @@ -221,7 +251,16 @@ private fun ConversationTopAppBarCallAction( @Composable private fun ConversationTopAppBarOverflowMenu( + isAddPeopleVisible: Boolean, + isArchiveVisible: Boolean, + isUnarchiveVisible: Boolean, + isAddContactVisible: Boolean, + isDeleteConversationVisible: Boolean, onAddPeopleClick: () -> Unit, + onArchiveClick: () -> Unit, + onUnarchiveClick: () -> Unit, + onAddContactClick: () -> Unit, + onDeleteConversationClick: () -> Unit, ) { var isExpanded by remember { mutableStateOf(value = false) } @@ -242,23 +281,91 @@ private fun ConversationTopAppBarOverflowMenu( isExpanded = false }, ) { - DropdownMenuItem( - modifier = Modifier.testTag(CONVERSATION_ADD_PEOPLE_BUTTON_TEST_TAG), - text = { - Text(text = stringResource(id = R.string.conversation_add_people)) - }, - leadingIcon = { - Icon( - imageVector = Icons.Rounded.GroupAdd, - contentDescription = null, - ) - }, - onClick = { - @Suppress("AssignedValueIsNeverRead") - isExpanded = false - onAddPeopleClick() - }, - ) + val dismissAndInvoke: (() -> Unit) -> Unit = { action -> + @Suppress("AssignedValueIsNeverRead") + isExpanded = false + action() + } + + if (isAddPeopleVisible) { + DropdownMenuItem( + modifier = Modifier.testTag(CONVERSATION_ADD_PEOPLE_BUTTON_TEST_TAG), + text = { + Text(text = stringResource(id = R.string.conversation_add_people)) + }, + leadingIcon = { + Icon( + imageVector = Icons.Rounded.GroupAdd, + contentDescription = null, + ) + }, + onClick = { dismissAndInvoke(onAddPeopleClick) }, + ) + } + + if (isAddContactVisible) { + DropdownMenuItem( + modifier = Modifier.testTag(CONVERSATION_ADD_CONTACT_BUTTON_TEST_TAG), + text = { + Text(text = stringResource(id = R.string.action_add_contact)) + }, + leadingIcon = { + Icon( + imageVector = Icons.Rounded.PersonAdd, + contentDescription = null, + ) + }, + onClick = { dismissAndInvoke(onAddContactClick) }, + ) + } + + if (isArchiveVisible) { + DropdownMenuItem( + modifier = Modifier.testTag(CONVERSATION_ARCHIVE_BUTTON_TEST_TAG), + text = { + Text(text = stringResource(id = R.string.action_archive)) + }, + leadingIcon = { + Icon( + imageVector = Icons.Rounded.Archive, + contentDescription = null, + ) + }, + onClick = { dismissAndInvoke(onArchiveClick) }, + ) + } + + if (isUnarchiveVisible) { + DropdownMenuItem( + modifier = Modifier.testTag(CONVERSATION_UNARCHIVE_BUTTON_TEST_TAG), + text = { + Text(text = stringResource(id = R.string.action_unarchive)) + }, + leadingIcon = { + Icon( + imageVector = Icons.Rounded.Unarchive, + contentDescription = null, + ) + }, + onClick = { dismissAndInvoke(onUnarchiveClick) }, + ) + } + + if (isDeleteConversationVisible) { + DropdownMenuItem( + modifier = Modifier.testTag(CONVERSATION_DELETE_CONVERSATION_BUTTON_TEST_TAG), + text = { + Text(text = stringResource(id = R.string.action_delete)) + }, + leadingIcon = { + Icon( + imageVector = Icons.Rounded.Delete, + contentDescription = null, + ) + }, + onClick = { dismissAndInvoke(onDeleteConversationClick) }, + ) + } } } diff --git a/src/com/android/messaging/ui/conversation/v2/screen/ConversationScreen.kt b/src/com/android/messaging/ui/conversation/v2/screen/ConversationScreen.kt index ab40d137..135e02ee 100644 --- a/src/com/android/messaging/ui/conversation/v2/screen/ConversationScreen.kt +++ b/src/com/android/messaging/ui/conversation/v2/screen/ConversationScreen.kt @@ -127,6 +127,7 @@ internal fun ConversationScreen( ConversationScreenEffects( screenModel = screenModel, hostBoundsState = hostBoundsState, + onNavigateBack = onNavigateBack, ) Box( @@ -147,6 +148,12 @@ internal fun ConversationScreen( onCallClick = screenModel::onCallClick, onConversationDetailsClick = onConversationDetailsClick, onNavigateBack = onNavigateBack, + onArchiveConversationClick = screenModel::onArchiveConversationClick, + onUnarchiveConversationClick = screenModel::onUnarchiveConversationClick, + onAddContactClick = screenModel::onAddContactClick, + onDeleteConversationClick = screenModel::onDeleteConversationClick, + onDeleteConversationConfirmed = screenModel::confirmDeleteConversation, + onDeleteConversationDismissed = screenModel::dismissDeleteConversationConfirmation, onDeleteSelectedMessagesConfirmed = screenModel::confirmDeleteSelectedMessages, onDeleteSelectedMessagesDismissed = screenModel::dismissDeleteMessageConfirmation, onDismissMessageSelection = screenModel::dismissMessageSelection, @@ -193,6 +200,12 @@ private fun ConversationScreenScaffold( onAddPeopleClick: () -> Unit, onCallClick: () -> Unit, onConversationDetailsClick: () -> Unit, + onArchiveConversationClick: () -> Unit, + onUnarchiveConversationClick: () -> Unit, + onAddContactClick: () -> Unit, + onDeleteConversationClick: () -> Unit, + onDeleteConversationConfirmed: () -> Unit, + onDeleteConversationDismissed: () -> Unit, onDeleteSelectedMessagesConfirmed: () -> Unit, onDeleteSelectedMessagesDismissed: () -> Unit, onDismissMessageSelection: () -> Unit, @@ -226,8 +239,16 @@ private fun ConversationScreenScaffold( metadata = uiState.metadata, isAddPeopleVisible = uiState.canAddPeople, isCallVisible = uiState.canCall, + isArchiveVisible = uiState.canArchive, + isUnarchiveVisible = uiState.canUnarchive, + isAddContactVisible = uiState.canAddContact, + isDeleteConversationVisible = uiState.canDeleteConversation, onAddPeopleClick = onAddPeopleClick, onCallClick = onCallClick, + onArchiveClick = onArchiveConversationClick, + onUnarchiveClick = onUnarchiveConversationClick, + onAddContactClick = onAddContactClick, + onDeleteConversationClick = onDeleteConversationClick, onTitleClick = onConversationDetailsClick, onNavigateBack = onNavigateBack, ) @@ -272,6 +293,42 @@ private fun ConversationScreenScaffold( onDismiss = onDeleteSelectedMessagesDismissed, ) } + + if (uiState.isDeleteConversationConfirmationVisible) { + ConversationDeleteConversationDialog( + onConfirm = onDeleteConversationConfirmed, + onDismiss = onDeleteConversationDismissed, + ) + } +} + +@Composable +private fun ConversationDeleteConversationDialog( + onConfirm: () -> Unit, + onDismiss: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { + Text( + text = pluralStringResource( + id = R.plurals.delete_conversations_confirmation_dialog_title, + count = 1, + 1, + ), + ) + }, + confirmButton = { + TextButton(onClick = onConfirm) { + Text(text = stringResource(R.string.delete_conversation_confirmation_button)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(text = stringResource(R.string.delete_conversation_decline_button)) + } + }, + ) } @Composable diff --git a/src/com/android/messaging/ui/conversation/v2/screen/ConversationScreenEffects.kt b/src/com/android/messaging/ui/conversation/v2/screen/ConversationScreenEffects.kt index 2a81def1..2c22cb5e 100644 --- a/src/com/android/messaging/ui/conversation/v2/screen/ConversationScreenEffects.kt +++ b/src/com/android/messaging/ui/conversation/v2/screen/ConversationScreenEffects.kt @@ -30,12 +30,24 @@ import kotlinx.coroutines.withContext internal fun ConversationScreenEffects( screenModel: ConversationScreenModel, hostBoundsState: State, + onNavigateBack: () -> Unit, ) { val context = LocalContext.current - LaunchedEffect(screenModel, context, hostBoundsState) { + LaunchedEffect(screenModel, context, hostBoundsState, onNavigateBack) { screenModel.effects.collect { effect -> when (effect) { + ConversationScreenEffect.CloseConversation -> { + onNavigateBack() + } + + is ConversationScreenEffect.LaunchAddContactFlow -> { + UIIntents.get().launchAddContactActivity( + context, + effect.destination, + ) + } + is ConversationScreenEffect.OpenAttachmentPreview -> { openAttachmentPreview( context = context, diff --git a/src/com/android/messaging/ui/conversation/v2/screen/ConversationViewModel.kt b/src/com/android/messaging/ui/conversation/v2/screen/ConversationViewModel.kt index bc6ca808..c24f8a02 100644 --- a/src/com/android/messaging/ui/conversation/v2/screen/ConversationViewModel.kt +++ b/src/com/android/messaging/ui/conversation/v2/screen/ConversationViewModel.kt @@ -12,15 +12,18 @@ import com.android.messaging.domain.conversation.usecase.IsDeviceVoiceCapable import com.android.messaging.ui.conversation.v2.composer.delegate.ConversationDraftDelegate import com.android.messaging.ui.conversation.v2.composer.mapper.ConversationComposerUiStateMapper import com.android.messaging.ui.conversation.v2.composer.model.ConversationComposerAttachmentUiState +import com.android.messaging.ui.conversation.v2.composer.model.ConversationComposerUiState import com.android.messaging.ui.conversation.v2.entry.model.ConversationEntryStartupAttachment import com.android.messaging.ui.conversation.v2.mediapicker.ConversationMediaPickerDelegate import com.android.messaging.ui.conversation.v2.mediapicker.model.ConversationCapturedMedia import com.android.messaging.ui.conversation.v2.messages.delegate.ConversationMessageSelectionDelegate import com.android.messaging.ui.conversation.v2.messages.delegate.ConversationMessagesDelegate +import com.android.messaging.ui.conversation.v2.messages.model.message.ConversationMessagesUiState import com.android.messaging.ui.conversation.v2.metadata.delegate.ConversationMetadataDelegate import com.android.messaging.ui.conversation.v2.metadata.model.ConversationMetadataUiState import com.android.messaging.ui.conversation.v2.screen.model.ConversationMediaPickerOverlayUiState import com.android.messaging.ui.conversation.v2.screen.model.ConversationMessageSelectionAction +import com.android.messaging.ui.conversation.v2.screen.model.ConversationMessageSelectionUiState import com.android.messaging.ui.conversation.v2.screen.model.ConversationScreenEffect import com.android.messaging.ui.conversation.v2.screen.model.ConversationScreenScaffoldUiState import dagger.hilt.android.lifecycle.HiltViewModel @@ -84,6 +87,13 @@ internal interface ConversationScreenModel { fun confirmDeleteSelectedMessages() fun onSendClick() fun persistDraft() + + fun onArchiveConversationClick() + fun onUnarchiveConversationClick() + fun onAddContactClick() + fun onDeleteConversationClick() + fun confirmDeleteConversation() + fun dismissDeleteConversationConfirmation() } @HiltViewModel @@ -136,34 +146,55 @@ internal class ConversationViewModel @Inject constructor( conversationMessagesDelegate.state, composerUiState, conversationMessageSelectionDelegate.state, - ) { metadataState, messagesUiState, composerUiState, selectionUiState -> - ConversationScreenScaffoldUiState( - canAddPeople = canAddPeople(metadataState = metadataState), - canCall = canCall(metadataState = metadataState), - metadata = metadataState, - messages = messagesUiState, - composer = composerUiState, - selection = selectionUiState, + conversationMetadataDelegate.isDeleteConversationConfirmationVisible, + ) { metadataState, messagesUiState, composerUiState, selectionUiState, isDeleteConfirmVisible -> + buildScaffoldUiState( + metadataState = metadataState, + messagesUiState = messagesUiState, + composerUiState = composerUiState, + selectionUiState = selectionUiState, + isDeleteConversationConfirmationVisible = isDeleteConfirmVisible, ) }.stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed( stopTimeoutMillis = STATEFLOW_STOP_TIMEOUT_MILLIS, ), - initialValue = ConversationScreenScaffoldUiState( - canAddPeople = canAddPeople( - metadataState = conversationMetadataDelegate.state.value, - ), - canCall = canCall( - metadataState = conversationMetadataDelegate.state.value, - ), - metadata = conversationMetadataDelegate.state.value, - messages = conversationMessagesDelegate.state.value, - composer = composerUiState.value, - selection = conversationMessageSelectionDelegate.state.value, + initialValue = buildScaffoldUiState( + metadataState = conversationMetadataDelegate.state.value, + messagesUiState = conversationMessagesDelegate.state.value, + composerUiState = composerUiState.value, + selectionUiState = conversationMessageSelectionDelegate.state.value, + isDeleteConversationConfirmationVisible = + conversationMetadataDelegate.isDeleteConversationConfirmationVisible.value, ), ) + private fun buildScaffoldUiState( + metadataState: ConversationMetadataUiState, + messagesUiState: ConversationMessagesUiState, + composerUiState: ConversationComposerUiState, + selectionUiState: ConversationMessageSelectionUiState, + isDeleteConversationConfirmationVisible: Boolean, + ): ConversationScreenScaffoldUiState { + val isPresent = metadataState is ConversationMetadataUiState.Present + val presentMetadata = metadataState as? ConversationMetadataUiState.Present + + return ConversationScreenScaffoldUiState( + canAddPeople = canAddPeople(metadataState = metadataState), + canCall = canCall(metadataState = metadataState), + canArchive = isPresent && presentMetadata?.isArchived == false, + canUnarchive = isPresent && presentMetadata?.isArchived == true, + canAddContact = canAddContact(metadataState = metadataState), + canDeleteConversation = isPresent, + isDeleteConversationConfirmationVisible = isDeleteConversationConfirmationVisible, + metadata = metadataState, + messages = messagesUiState, + composer = composerUiState, + selection = selectionUiState, + ) + } + override val mediaPickerOverlayUiState: StateFlow = combine( conversationMetadataDelegate.state, @@ -229,6 +260,9 @@ internal class ConversationViewModel @Inject constructor( viewModelScope.launch(defaultDispatcher) { conversationMessageSelectionDelegate.effects.collect(_effects::emit) } + viewModelScope.launch(defaultDispatcher) { + conversationMetadataDelegate.effects.collect(_effects::emit) + } } override fun onConversationIdChanged(conversationId: String?) { @@ -264,6 +298,15 @@ internal class ConversationViewModel @Inject constructor( return isOneOnOne && isDeviceVoiceCapable() } + private fun canAddContact( + metadataState: ConversationMetadataUiState, + ): Boolean { + val present = metadataState as? ConversationMetadataUiState.Present ?: return false + val hasDestination = !present.otherParticipantPhoneNumber.isNullOrBlank() + val hasContactLink = !present.otherParticipantContactLookupKey.isNullOrBlank() + return !present.isGroupConversation && hasDestination && !hasContactLink + } + override fun onSeedDraft( conversationId: String, draft: ConversationDraft, @@ -427,6 +470,30 @@ internal class ConversationViewModel @Inject constructor( conversationDraftDelegate.persistDraft() } + override fun onArchiveConversationClick() { + conversationMetadataDelegate.onArchiveConversationClick() + } + + override fun onUnarchiveConversationClick() { + conversationMetadataDelegate.onUnarchiveConversationClick() + } + + override fun onAddContactClick() { + conversationMetadataDelegate.onAddContactClick() + } + + override fun onDeleteConversationClick() { + conversationMetadataDelegate.onDeleteConversationClick() + } + + override fun confirmDeleteConversation() { + conversationMetadataDelegate.confirmDeleteConversation() + } + + override fun dismissDeleteConversationConfirmation() { + conversationMetadataDelegate.dismissDeleteConversationConfirmation() + } + override fun onCleared() { conversationMediaPickerDelegate.onScreenCleared() conversationDraftDelegate.flushDraft() diff --git a/src/com/android/messaging/ui/conversation/v2/screen/model/ConversationScreenEffect.kt b/src/com/android/messaging/ui/conversation/v2/screen/model/ConversationScreenEffect.kt index 62fcad0e..1a9c4f2d 100644 --- a/src/com/android/messaging/ui/conversation/v2/screen/model/ConversationScreenEffect.kt +++ b/src/com/android/messaging/ui/conversation/v2/screen/model/ConversationScreenEffect.kt @@ -6,6 +6,12 @@ import com.android.messaging.datamodel.data.MessageData import com.android.messaging.datamodel.data.ParticipantData internal sealed interface ConversationScreenEffect { + data object CloseConversation : ConversationScreenEffect + + data class LaunchAddContactFlow( + val destination: String, + ) : ConversationScreenEffect + data class LaunchForwardMessage( val message: MessageData, ) : ConversationScreenEffect diff --git a/src/com/android/messaging/ui/conversation/v2/screen/model/ConversationScreenScaffoldUiState.kt b/src/com/android/messaging/ui/conversation/v2/screen/model/ConversationScreenScaffoldUiState.kt index 83cda0ab..1abdfbd6 100644 --- a/src/com/android/messaging/ui/conversation/v2/screen/model/ConversationScreenScaffoldUiState.kt +++ b/src/com/android/messaging/ui/conversation/v2/screen/model/ConversationScreenScaffoldUiState.kt @@ -9,6 +9,11 @@ import com.android.messaging.ui.conversation.v2.metadata.model.ConversationMetad internal data class ConversationScreenScaffoldUiState( val canAddPeople: Boolean = false, val canCall: Boolean = false, + val canArchive: Boolean = false, + val canUnarchive: Boolean = false, + val canAddContact: Boolean = false, + val canDeleteConversation: Boolean = false, + val isDeleteConversationConfirmationVisible: Boolean = false, val metadata: ConversationMetadataUiState = ConversationMetadataUiState.Loading, val messages: ConversationMessagesUiState = ConversationMessagesUiState.Loading, val composer: ConversationComposerUiState = ConversationComposerUiState(), From 53d7c96cdf99604cfb2251bd28d9dc05786651d9 Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Mon, 20 Apr 2026 22:19:57 +0300 Subject: [PATCH 45/99] Add conversation subscription repository and debug SIM emulation --- .../metadata/ConversationSubscription.kt | 12 + .../metadata/ConversationSubscriptionLabel.kt | 22 ++ .../ConversationSubscriptionsRepository.kt | 205 ++++++++++++++++++ .../messaging/debug/DebugSimEmulation.kt | 51 +++++ .../conversation/ConversationBindsModule.kt | 8 + .../messaging/di/core/DebugProvidesModule.kt | 18 ++ .../android/messaging/util/DebugUtils.java | 48 ++++ 7 files changed, 364 insertions(+) create mode 100644 src/com/android/messaging/data/conversation/model/metadata/ConversationSubscription.kt create mode 100644 src/com/android/messaging/data/conversation/model/metadata/ConversationSubscriptionLabel.kt create mode 100644 src/com/android/messaging/data/conversation/repository/ConversationSubscriptionsRepository.kt create mode 100644 src/com/android/messaging/debug/DebugSimEmulation.kt create mode 100644 src/com/android/messaging/di/core/DebugProvidesModule.kt diff --git a/src/com/android/messaging/data/conversation/model/metadata/ConversationSubscription.kt b/src/com/android/messaging/data/conversation/model/metadata/ConversationSubscription.kt new file mode 100644 index 00000000..95cdc7e3 --- /dev/null +++ b/src/com/android/messaging/data/conversation/model/metadata/ConversationSubscription.kt @@ -0,0 +1,12 @@ +package com.android.messaging.data.conversation.model.metadata + +import androidx.compose.runtime.Immutable + +@Immutable +internal data class ConversationSubscription( + val selfParticipantId: String, + val label: ConversationSubscriptionLabel, + val displayDestination: String?, + val displaySlotId: Int, + val color: Int, +) diff --git a/src/com/android/messaging/data/conversation/model/metadata/ConversationSubscriptionLabel.kt b/src/com/android/messaging/data/conversation/model/metadata/ConversationSubscriptionLabel.kt new file mode 100644 index 00000000..89d13511 --- /dev/null +++ b/src/com/android/messaging/data/conversation/model/metadata/ConversationSubscriptionLabel.kt @@ -0,0 +1,22 @@ +package com.android.messaging.data.conversation.model.metadata + +import androidx.compose.runtime.Immutable + +@Immutable +internal sealed interface ConversationSubscriptionLabel { + + @Immutable + data class Named( + val name: String, + ) : ConversationSubscriptionLabel + + @Immutable + data class Slot( + val slotId: Int, + ) : ConversationSubscriptionLabel + + @Immutable + data class DebugFake( + val slotId: Int, + ) : ConversationSubscriptionLabel +} diff --git a/src/com/android/messaging/data/conversation/repository/ConversationSubscriptionsRepository.kt b/src/com/android/messaging/data/conversation/repository/ConversationSubscriptionsRepository.kt new file mode 100644 index 00000000..5f762967 --- /dev/null +++ b/src/com/android/messaging/data/conversation/repository/ConversationSubscriptionsRepository.kt @@ -0,0 +1,205 @@ +package com.android.messaging.data.conversation.repository + +import android.content.ContentResolver +import android.database.ContentObserver +import android.net.Uri +import com.android.messaging.data.conversation.model.metadata.ConversationSubscription +import com.android.messaging.data.conversation.model.metadata.ConversationSubscriptionLabel +import com.android.messaging.datamodel.DatabaseHelper.ParticipantColumns +import com.android.messaging.datamodel.MessagingContentProvider +import com.android.messaging.datamodel.data.ParticipantData +import com.android.messaging.debug.DebugSimEmulationMode +import com.android.messaging.debug.DebugSimEmulationSource +import com.android.messaging.di.core.IoDispatcher +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.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.conflate +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map + +internal interface ConversationSubscriptionsRepository { + fun observeActiveSubscriptions(): Flow> +} + +internal class ConversationSubscriptionsRepositoryImpl @Inject constructor( + private val contentResolver: ContentResolver, + private val debugSimEmulationSource: DebugSimEmulationSource, + @param:IoDispatcher + private val ioDispatcher: CoroutineDispatcher, +) : ConversationSubscriptionsRepository { + + override fun observeActiveSubscriptions(): Flow> { + val uri = MessagingContentProvider.PARTICIPANTS_URI + + val realSubscriptions = observeUri(uri = uri) + .conflate() + .map { + queryActiveSubscriptions() + } + .flowOn(ioDispatcher) + + return combine( + realSubscriptions, + debugSimEmulationSource.mode, + ) { subscriptions, emulationMode -> + applyDebugEmulation( + subscriptions = subscriptions, + mode = emulationMode, + ) + } + } + + private fun applyDebugEmulation( + subscriptions: ImmutableList, + mode: DebugSimEmulationMode, + ): ImmutableList { + return when (mode) { + DebugSimEmulationMode.DEFAULT -> subscriptions + DebugSimEmulationMode.SINGLE -> applySingleSimEmulation(subscriptions = subscriptions) + DebugSimEmulationMode.DUAL -> applyDualSimEmulation(subscriptions = subscriptions) + } + } + + private fun applySingleSimEmulation( + subscriptions: ImmutableList, + ): ImmutableList { + val hasRealSubscription = subscriptions.isNotEmpty() + + if (hasRealSubscription) { + return subscriptions + } + + return persistentListOf( + fakeSubscription(slotId = 1, colorIndex = 0), + ) + } + + private fun applyDualSimEmulation( + subscriptions: ImmutableList, + ): ImmutableList { + return when (subscriptions.size) { + 0 -> { + persistentListOf( + fakeSubscription(slotId = 1, colorIndex = 0), + fakeSubscription(slotId = 2, colorIndex = 1), + ) + } + + 1 -> pairRealSubscriptionWithFake(realSubscription = subscriptions.first()) + + else -> subscriptions + } + } + + private fun pairRealSubscriptionWithFake( + realSubscription: ConversationSubscription, + ): ImmutableList { + val fakeSlot = when (realSubscription.displaySlotId) { + 1 -> 2 + else -> 1 + } + return sequenceOf( + realSubscription, + fakeSubscription(slotId = fakeSlot, colorIndex = 1), + ) + .sortedBy { subscription -> subscription.displaySlotId } + .toImmutableList() + } + + private fun fakeSubscription( + slotId: Int, + colorIndex: Int, + ): ConversationSubscription { + return ConversationSubscription( + selfParticipantId = "$FAKE_SIM_ID_PREFIX$slotId", + label = ConversationSubscriptionLabel.DebugFake(slotId = slotId), + displayDestination = null, + displaySlotId = slotId, + color = FAKE_SIM_COLORS[colorIndex % FAKE_SIM_COLORS.size], + ) + } + + private fun observeUri(uri: Uri): Flow { + return callbackFlow { + val observer = object : ContentObserver(null) { + override fun onChange(selfChange: Boolean) { + trySend(Unit) + } + } + contentResolver.registerContentObserver(uri, true, observer) + + trySend(Unit) + + awaitClose { + contentResolver.unregisterContentObserver(observer) + } + } + } + + private fun queryActiveSubscriptions(): ImmutableList { + return contentResolver + .query( + MessagingContentProvider.PARTICIPANTS_URI, + ParticipantData.ParticipantsQuery.PROJECTION, + "${ParticipantColumns.SUB_ID} <> ?", + arrayOf(ParticipantData.OTHER_THAN_SELF_SUB_ID.toString()), + null, + ) + ?.use { cursor -> + val subscriptions = persistentListOf().builder() + + while (cursor.moveToNext()) { + val participant = ParticipantData.getFromCursor(cursor) + + val shouldSkip = !participant.isSelf || + participant.isDefaultSelf || + !participant.isActiveSubscription + + if (shouldSkip) { + continue + } + + subscriptions.add(participant.toConversationSubscription()) + } + + subscriptions + .build() + .sortedBy { subscription -> subscription.displaySlotId } + .toImmutableList() + } + ?: persistentListOf() + } + + private companion object { + private const val FAKE_SIM_ID_PREFIX = "debug_sim_emulated_" + private val FAKE_SIM_COLORS = intArrayOf( + 0xFF5E9BE8.toInt(), + 0xFFE97E6A.toInt(), + ) + } + + private fun ParticipantData.toConversationSubscription(): ConversationSubscription { + val slotId = displaySlotId + + return ConversationSubscription( + selfParticipantId = id, + label = when { + subscriptionName.isNullOrBlank() -> ConversationSubscriptionLabel.Slot( + slotId = slotId, + ) + + else -> ConversationSubscriptionLabel.Named(name = subscriptionName) + }, + displayDestination = displayDestination?.takeIf { it.isNotBlank() }, + displaySlotId = slotId, + color = subscriptionColor, + ) + } +} diff --git a/src/com/android/messaging/debug/DebugSimEmulation.kt b/src/com/android/messaging/debug/DebugSimEmulation.kt new file mode 100644 index 00000000..92642835 --- /dev/null +++ b/src/com/android/messaging/debug/DebugSimEmulation.kt @@ -0,0 +1,51 @@ +package com.android.messaging.debug + +import com.android.messaging.util.BuglePrefs +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +enum class DebugSimEmulationMode { + DEFAULT, + SINGLE, + DUAL, +} + +internal interface DebugSimEmulationSource { + val mode: StateFlow +} + +object DebugSimEmulationStore : DebugSimEmulationSource { + + private const val PREF_KEY = "debug_sim_emulation_mode" + + private val _mode: MutableStateFlow by lazy { + MutableStateFlow(value = loadPersistedMode()) + } + + override val mode = _mode.asStateFlow() + + @JvmStatic + fun getCurrentMode(): DebugSimEmulationMode { + return _mode.value + } + + @JvmStatic + fun setMode(mode: DebugSimEmulationMode) { + if (_mode.value == mode) { + return + } + + _mode.value = mode + BuglePrefs.getApplicationPrefs().putString(PREF_KEY, mode.name) + } + + private fun loadPersistedMode(): DebugSimEmulationMode { + val stored = BuglePrefs + .getApplicationPrefs() + .getString(PREF_KEY, DebugSimEmulationMode.DEFAULT.name) + + return runCatching { DebugSimEmulationMode.valueOf(value = stored) } + .getOrDefault(defaultValue = DebugSimEmulationMode.DEFAULT) + } +} diff --git a/src/com/android/messaging/di/conversation/ConversationBindsModule.kt b/src/com/android/messaging/di/conversation/ConversationBindsModule.kt index 3125749b..738ca36f 100644 --- a/src/com/android/messaging/di/conversation/ConversationBindsModule.kt +++ b/src/com/android/messaging/di/conversation/ConversationBindsModule.kt @@ -14,6 +14,8 @@ import com.android.messaging.data.conversation.repository.ConversationParticipan import com.android.messaging.data.conversation.repository.ConversationParticipantsRepositoryImpl import com.android.messaging.data.conversation.repository.ConversationRecipientsRepository import com.android.messaging.data.conversation.repository.ConversationRecipientsRepositoryImpl +import com.android.messaging.data.conversation.repository.ConversationSubscriptionsRepository +import com.android.messaging.data.conversation.repository.ConversationSubscriptionsRepositoryImpl import com.android.messaging.data.conversation.repository.ConversationsRepository import com.android.messaging.data.conversation.repository.ConversationsRepositoryImpl import com.android.messaging.data.media.repository.ConversationMediaRepository @@ -142,6 +144,12 @@ internal abstract class ConversationBindsModule { impl: ConversationsRepositoryImpl, ): ConversationsRepository + @Binds + @Reusable + abstract fun bindConversationSubscriptionsRepository( + impl: ConversationSubscriptionsRepositoryImpl, + ): ConversationSubscriptionsRepository + @Binds abstract fun bindConversationAttachmentBridge( impl: ConversationAttachmentBridgeImpl, diff --git a/src/com/android/messaging/di/core/DebugProvidesModule.kt b/src/com/android/messaging/di/core/DebugProvidesModule.kt new file mode 100644 index 00000000..59d1c59e --- /dev/null +++ b/src/com/android/messaging/di/core/DebugProvidesModule.kt @@ -0,0 +1,18 @@ +package com.android.messaging.di.core + +import com.android.messaging.debug.DebugSimEmulationSource +import com.android.messaging.debug.DebugSimEmulationStore +import dagger.Module +import dagger.Provides +import dagger.Reusable +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +@Module +@InstallIn(SingletonComponent::class) +internal object DebugProvidesModule { + + @Provides + @Reusable + fun provideDebugSimEmulationSource(): DebugSimEmulationSource = DebugSimEmulationStore +} diff --git a/src/com/android/messaging/util/DebugUtils.java b/src/com/android/messaging/util/DebugUtils.java index 7212c42c..20614340 100644 --- a/src/com/android/messaging/util/DebugUtils.java +++ b/src/com/android/messaging/util/DebugUtils.java @@ -39,6 +39,8 @@ import com.android.messaging.datamodel.SyncManager; import com.android.messaging.datamodel.action.DumpDatabaseAction; import com.android.messaging.datamodel.action.LogTelephonyDatabaseAction; +import com.android.messaging.debug.DebugSimEmulationMode; +import com.android.messaging.debug.DebugSimEmulationStore; import com.android.messaging.debug.TestDataSeeder; import com.android.messaging.sms.MmsUtils; import com.android.messaging.ui.UIIntents; @@ -217,6 +219,13 @@ public void run() { } }); + arrayAdapter.add(new DebugAction("SIM emulation mode...") { + @Override + public void run() { + showSimEmulationModeDialog(host); + } + }); + builder.setAdapter(arrayAdapter, new android.content.DialogInterface.OnClickListener() { @Override @@ -228,6 +237,45 @@ public void onClick(final DialogInterface arg0, final int pos) { builder.create().show(); } + private static void showSimEmulationModeDialog(final AppCompatActivity host) { + final DebugSimEmulationMode[] modes = DebugSimEmulationMode.values(); + final String[] labels = new String[modes.length]; + int checkedIndex = 0; + final DebugSimEmulationMode currentMode = DebugSimEmulationStore.getCurrentMode(); + for (int i = 0; i < modes.length; i++) { + labels[i] = describeSimEmulationMode(modes[i]); + if (modes[i] == currentMode) { + checkedIndex = i; + } + } + final int[] selectedIndex = new int[] { checkedIndex }; + new AlertDialog.Builder(host) + .setTitle("SIM emulation mode") + .setSingleChoiceItems(labels, checkedIndex, + (dialog, which) -> selectedIndex[0] = which) + .setPositiveButton(android.R.string.ok, (dialog, which) -> { + final DebugSimEmulationMode newMode = modes[selectedIndex[0]]; + DebugSimEmulationStore.setMode(newMode); + Toast.makeText(host, + "SIM emulation: " + labels[selectedIndex[0]], + Toast.LENGTH_SHORT).show(); + }) + .setNegativeButton(android.R.string.cancel, null) + .show(); + } + + private static String describeSimEmulationMode(final DebugSimEmulationMode mode) { + switch (mode) { + case SINGLE: + return "Single SIM (emulate 1 if none present)"; + case DUAL: + return "Dual SIM (emulate up to 2 if fewer present)"; + case DEFAULT: + default: + return "Default (use real SIMs)"; + } + } + /** * Task to list all the dump files and perform an action on it */ From 5dc6f74e7b3a791c99f8ce041a856b2b0f9a69b1 Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Mon, 20 Apr 2026 22:20:22 +0300 Subject: [PATCH 46/99] Add conversation SIM selection from overflow menu --- res/values/strings.xml | 6 + .../ConversationSubscriptionLabelResolver.kt | 27 +++ .../conversation/v2/ConversationTestTags.kt | 7 + .../delegate/ConversationDraftDelegate.kt | 8 + .../delegate/ConversationDraftEditorState.kt | 14 ++ .../ConversationComposerUiStateMapper.kt | 22 +++ .../model/ConversationComposerUiState.kt | 1 + .../model/ConversationSimSelectorUiState.kt | 15 ++ .../ui/ConversationSimSelectorSheet.kt | 187 ++++++++++++++++++ .../delegate/ConversationMetadataDelegate.kt | 12 +- .../v2/metadata/ui/ConversationTopAppBar.kt | 167 +++++++++------- .../v2/screen/ConversationScreen.kt | 29 +++ .../v2/screen/ConversationViewModel.kt | 26 ++- 13 files changed, 441 insertions(+), 80 deletions(-) create mode 100644 src/com/android/messaging/ui/conversation/v2/ConversationSubscriptionLabelResolver.kt create mode 100644 src/com/android/messaging/ui/conversation/v2/composer/model/ConversationSimSelectorUiState.kt create mode 100644 src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationSimSelectorSheet.kt diff --git a/res/values/strings.xml b/res/values/strings.xml index 40b737e7..571531ce 100644 --- a/res/values/strings.xml +++ b/res/values/strings.xml @@ -939,6 +939,12 @@ SIM selector %1$s selected, SIM selector + + Send from + + Selected + + Debug SIM %s Edit subject diff --git a/src/com/android/messaging/ui/conversation/v2/ConversationSubscriptionLabelResolver.kt b/src/com/android/messaging/ui/conversation/v2/ConversationSubscriptionLabelResolver.kt new file mode 100644 index 00000000..b0c501b8 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/v2/ConversationSubscriptionLabelResolver.kt @@ -0,0 +1,27 @@ +package com.android.messaging.ui.conversation.v2 + +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import com.android.messaging.R +import com.android.messaging.data.conversation.model.metadata.ConversationSubscriptionLabel + +@Composable +internal fun ConversationSubscriptionLabel.resolveDisplayName(): String { + return when (this) { + is ConversationSubscriptionLabel.Named -> name + + is ConversationSubscriptionLabel.Slot -> { + stringResource( + id = R.string.sim_slot_identifier, + slotId.toString(), + ) + } + + is ConversationSubscriptionLabel.DebugFake -> { + stringResource( + id = R.string.debug_emulated_sim_display_name, + slotId.toString(), + ) + } + } +} diff --git a/src/com/android/messaging/ui/conversation/v2/ConversationTestTags.kt b/src/com/android/messaging/ui/conversation/v2/ConversationTestTags.kt index df74fae2..9f5cb31f 100644 --- a/src/com/android/messaging/ui/conversation/v2/ConversationTestTags.kt +++ b/src/com/android/messaging/ui/conversation/v2/ConversationTestTags.kt @@ -27,6 +27,13 @@ internal const val NEW_CHAT_CONTACT_RESOLVING_INDICATOR_TEST_TAG = internal const val CONVERSATION_SEND_BUTTON_SHAPE_CIRCLE = "circle" internal const val CONVERSATION_SEND_BUTTON_TEST_TAG = "conversation_send_button" internal const val CONVERSATION_TEXT_FIELD_TEST_TAG = "conversation_text_field" +internal const val CONVERSATION_SIM_SELECTOR_MENU_ITEM_TEST_TAG = + "conversation_sim_selector_menu_item" +internal const val CONVERSATION_SIM_SELECTOR_SHEET_TEST_TAG = "conversation_sim_selector_sheet" + +internal fun conversationSimSelectorItemTestTag(selfParticipantId: String): String { + return "conversation_sim_selector_item_$selfParticipantId" +} internal fun conversationMessageItemTestTag(messageId: String): String { return "conversation_message_item_$messageId" diff --git a/src/com/android/messaging/ui/conversation/v2/composer/delegate/ConversationDraftDelegate.kt b/src/com/android/messaging/ui/conversation/v2/composer/delegate/ConversationDraftDelegate.kt index 121fb2b0..cafbee4e 100644 --- a/src/com/android/messaging/ui/conversation/v2/composer/delegate/ConversationDraftDelegate.kt +++ b/src/com/android/messaging/ui/conversation/v2/composer/delegate/ConversationDraftDelegate.kt @@ -41,6 +41,8 @@ import kotlinx.coroutines.withContext internal interface ConversationDraftDelegate : ConversationScreenDelegate { fun onMessageTextChanged(messageText: String) + fun onSelfParticipantIdChanged(selfParticipantId: String) + fun seedDraft( conversationId: String, draft: ConversationDraft, @@ -113,6 +115,12 @@ internal class ConversationDraftDelegateImpl @Inject constructor( } } + override fun onSelfParticipantIdChanged(selfParticipantId: String) { + updateDraftEditorState { currentDraftEditorState -> + currentDraftEditorState.withSelfParticipantId(selfParticipantId = selfParticipantId) + } + } + override fun seedDraft( conversationId: String, draft: ConversationDraft, diff --git a/src/com/android/messaging/ui/conversation/v2/composer/delegate/ConversationDraftEditorState.kt b/src/com/android/messaging/ui/conversation/v2/composer/delegate/ConversationDraftEditorState.kt index 39325ff4..1c99d20f 100644 --- a/src/com/android/messaging/ui/conversation/v2/composer/delegate/ConversationDraftEditorState.kt +++ b/src/com/android/messaging/ui/conversation/v2/composer/delegate/ConversationDraftEditorState.kt @@ -68,6 +68,20 @@ internal data class DraftEditorState( } } + fun withSelfParticipantId(selfParticipantId: String): DraftEditorState { + return when { + conversationId == null -> this + selfParticipantId.isBlank() -> this + effectiveDraft.selfParticipantId == selfParticipantId -> this + + else -> { + copyWithNormalizedLocalEdits( + updatedLocalEdits = localEdits.copy(selfParticipantId = selfParticipantId), + ) + } + } + } + fun withSeededDraft(draft: ConversationDraft): DraftEditorState { if (conversationId == null) { return this diff --git a/src/com/android/messaging/ui/conversation/v2/composer/mapper/ConversationComposerUiStateMapper.kt b/src/com/android/messaging/ui/conversation/v2/composer/mapper/ConversationComposerUiStateMapper.kt index ee94d556..a46ecdd8 100644 --- a/src/com/android/messaging/ui/conversation/v2/composer/mapper/ConversationComposerUiStateMapper.kt +++ b/src/com/android/messaging/ui/conversation/v2/composer/mapper/ConversationComposerUiStateMapper.kt @@ -1,9 +1,11 @@ package com.android.messaging.ui.conversation.v2.composer.mapper import com.android.messaging.data.conversation.model.metadata.ConversationComposerAvailability +import com.android.messaging.data.conversation.model.metadata.ConversationSubscription import com.android.messaging.ui.conversation.v2.composer.model.ConversationComposerAttachmentUiState import com.android.messaging.ui.conversation.v2.composer.model.ConversationComposerUiState import com.android.messaging.ui.conversation.v2.composer.model.ConversationDraftState +import com.android.messaging.ui.conversation.v2.composer.model.ConversationSimSelectorUiState import javax.inject.Inject import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList @@ -12,6 +14,7 @@ internal interface ConversationComposerUiStateMapper { fun map( draftState: ConversationDraftState, composerAvailability: ConversationComposerAvailability, + subscriptions: ImmutableList, ): ConversationComposerUiState } @@ -21,6 +24,7 @@ internal class ConversationComposerUiStateMapperImpl @Inject constructor() : override fun map( draftState: ConversationDraftState, composerAvailability: ConversationComposerAvailability, + subscriptions: ImmutableList, ): ConversationComposerUiState { val draft = draftState.draft val hasWorkingDraft = draft.hasContent @@ -42,6 +46,10 @@ internal class ConversationComposerUiStateMapperImpl @Inject constructor() : messageText = draft.messageText, subjectText = draft.subjectText, selfParticipantId = draft.selfParticipantId, + simSelector = buildSimSelectorUiState( + subscriptions = subscriptions, + selfParticipantId = draft.selfParticipantId, + ), isMessageFieldEnabled = isMessageFieldEnabled, isAttachmentActionEnabled = isAttachmentActionEnabled, isSendEnabled = isSendEnabled, @@ -57,6 +65,20 @@ internal class ConversationComposerUiStateMapperImpl @Inject constructor() : ) } + private fun buildSimSelectorUiState( + subscriptions: ImmutableList, + selfParticipantId: String, + ): ConversationSimSelectorUiState { + val selected = subscriptions + .firstOrNull { it.selfParticipantId == selfParticipantId } + ?: subscriptions.firstOrNull() + + return ConversationSimSelectorUiState( + subscriptions = subscriptions, + selectedSubscription = selected, + ) + } + private fun ConversationDraftState.toAttachmentUiState(): ImmutableList { val resolvedAttachments = draft.attachments.map { attachment -> diff --git a/src/com/android/messaging/ui/conversation/v2/composer/model/ConversationComposerUiState.kt b/src/com/android/messaging/ui/conversation/v2/composer/model/ConversationComposerUiState.kt index 3237f770..61c5fc53 100644 --- a/src/com/android/messaging/ui/conversation/v2/composer/model/ConversationComposerUiState.kt +++ b/src/com/android/messaging/ui/conversation/v2/composer/model/ConversationComposerUiState.kt @@ -11,6 +11,7 @@ internal data class ConversationComposerUiState( val messageText: String = "", val subjectText: String = "", val selfParticipantId: String = "", + val simSelector: ConversationSimSelectorUiState = ConversationSimSelectorUiState(), val isMessageFieldEnabled: Boolean = false, val isAttachmentActionEnabled: Boolean = false, val isSendEnabled: Boolean = false, diff --git a/src/com/android/messaging/ui/conversation/v2/composer/model/ConversationSimSelectorUiState.kt b/src/com/android/messaging/ui/conversation/v2/composer/model/ConversationSimSelectorUiState.kt new file mode 100644 index 00000000..beab1a75 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/v2/composer/model/ConversationSimSelectorUiState.kt @@ -0,0 +1,15 @@ +package com.android.messaging.ui.conversation.v2.composer.model + +import androidx.compose.runtime.Immutable +import com.android.messaging.data.conversation.model.metadata.ConversationSubscription +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf + +@Immutable +internal data class ConversationSimSelectorUiState( + val subscriptions: ImmutableList = persistentListOf(), + val selectedSubscription: ConversationSubscription? = null, +) { + val isAvailable: Boolean + get() = subscriptions.size > 1 +} diff --git a/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationSimSelectorSheet.kt b/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationSimSelectorSheet.kt new file mode 100644 index 00000000..8450e457 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationSimSelectorSheet.kt @@ -0,0 +1,187 @@ +package com.android.messaging.ui.conversation.v2.composer.ui + +import androidx.compose.foundation.background +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.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Check +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.android.messaging.R +import com.android.messaging.data.conversation.model.metadata.ConversationSubscription +import com.android.messaging.ui.conversation.v2.CONVERSATION_SIM_SELECTOR_SHEET_TEST_TAG +import com.android.messaging.ui.conversation.v2.composer.model.ConversationSimSelectorUiState +import com.android.messaging.ui.conversation.v2.conversationSimSelectorItemTestTag +import com.android.messaging.ui.conversation.v2.resolveDisplayName + +private val SHEET_VERTICAL_PADDING = 8.dp + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun ConversationSimSelectorSheet( + uiState: ConversationSimSelectorUiState, + onSimSelected: (String) -> Unit, + onDismissRequest: () -> Unit, +) { + val sheetState = rememberModalBottomSheetState( + skipPartiallyExpanded = true, + ) + + ModalBottomSheet( + modifier = Modifier.testTag(tag = CONVERSATION_SIM_SELECTOR_SHEET_TEST_TAG), + onDismissRequest = onDismissRequest, + sheetState = sheetState, + ) { + ConversationSimSelectorSheetContent( + uiState = uiState, + onSimSelected = onSimSelected, + ) + } +} + +@Composable +private fun ConversationSimSelectorSheetContent( + uiState: ConversationSimSelectorUiState, + onSimSelected: (String) -> Unit, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .navigationBarsPadding() + .padding(vertical = SHEET_VERTICAL_PADDING), + ) { + Text( + modifier = Modifier.padding( + start = 24.dp, + end = 24.dp, + top = 8.dp, + bottom = 12.dp, + ), + text = stringResource(id = R.string.sim_selector_sheet_title), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + + uiState.subscriptions.forEach { subscription -> + val isSelected = subscription.selfParticipantId == + uiState.selectedSubscription?.selfParticipantId + + ConversationSimSelectorRow( + subscription = subscription, + isSelected = isSelected, + onClick = { onSimSelected(subscription.selfParticipantId) }, + ) + } + + Spacer(modifier = Modifier.height(height = SHEET_VERTICAL_PADDING)) + } +} + +@Composable +private fun ConversationSimSelectorRow( + subscription: ConversationSubscription, + isSelected: Boolean, + onClick: () -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .selectable( + selected = isSelected, + role = Role.RadioButton, + onClick = onClick, + ) + .testTag( + tag = conversationSimSelectorItemTestTag( + selfParticipantId = subscription.selfParticipantId, + ), + ) + .padding( + horizontal = 24.dp, + vertical = 12.dp, + ), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(space = 16.dp), + ) { + ConversationSimAvatar(subscription = subscription) + + Column(modifier = Modifier.weight(weight = 1f)) { + Text( + text = subscription.label.resolveDisplayName(), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + + subscription.displayDestination?.let { destination -> + Text( + text = destination, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + if (isSelected) { + Icon( + imageVector = Icons.Rounded.Check, + contentDescription = stringResource(id = R.string.sim_selector_item_selected), + tint = MaterialTheme.colorScheme.primary, + ) + } + } +} + +@Composable +private fun ConversationSimAvatar( + subscription: ConversationSubscription, +) { + Box( + modifier = Modifier + .size(size = 40.dp) + .clip(shape = CircleShape) + .background( + color = subscription.resolveAccentColor(), + ), + contentAlignment = Alignment.Center, + ) { + Text( + text = subscription.displaySlotId.toString(), + style = MaterialTheme.typography.titleMedium.copy( + fontWeight = FontWeight.SemiBold, + ), + color = Color.White, + ) + } +} + +@Composable +private fun ConversationSubscription.resolveAccentColor(): Color { + return when (color) { + 0 -> MaterialTheme.colorScheme.primary + else -> Color(color = color) + } +} diff --git a/src/com/android/messaging/ui/conversation/v2/metadata/delegate/ConversationMetadataDelegate.kt b/src/com/android/messaging/ui/conversation/v2/metadata/delegate/ConversationMetadataDelegate.kt index 05536e13..b455f9e9 100644 --- a/src/com/android/messaging/ui/conversation/v2/metadata/delegate/ConversationMetadataDelegate.kt +++ b/src/com/android/messaging/ui/conversation/v2/metadata/delegate/ConversationMetadataDelegate.kt @@ -95,15 +95,19 @@ internal class ConversationMetadataDelegateImpl @Inject constructor( } override fun onArchiveConversationClick() { + val conversationId = currentConversationId ?: return + boundScope?.launch(defaultDispatcher) { - currentConversationId?.let(conversationsRepository::archiveConversation) + conversationsRepository.archiveConversation(conversationId = conversationId) _effects.emit(ConversationScreenEffect.CloseConversation) } } override fun onUnarchiveConversationClick() { + val conversationId = currentConversationId ?: return + boundScope?.launch(defaultDispatcher) { - currentConversationId?.let(conversationsRepository::unarchiveConversation) + conversationsRepository.unarchiveConversation(conversationId = conversationId) } } @@ -127,10 +131,12 @@ internal class ConversationMetadataDelegateImpl @Inject constructor( } override fun confirmDeleteConversation() { + val conversationId = currentConversationId ?: return + _isDeleteConversationConfirmationVisible.value = false boundScope?.launch(defaultDispatcher) { - currentConversationId?.let(conversationsRepository::deleteConversation) + conversationsRepository.deleteConversation(conversationId = conversationId) _effects.emit(ConversationScreenEffect.CloseConversation) } } diff --git a/src/com/android/messaging/ui/conversation/v2/metadata/ui/ConversationTopAppBar.kt b/src/com/android/messaging/ui/conversation/v2/metadata/ui/ConversationTopAppBar.kt index 3aafd02a..695ebd0a 100644 --- a/src/com/android/messaging/ui/conversation/v2/metadata/ui/ConversationTopAppBar.kt +++ b/src/com/android/messaging/ui/conversation/v2/metadata/ui/ConversationTopAppBar.kt @@ -19,6 +19,7 @@ import androidx.compose.material.icons.rounded.GroupAdd import androidx.compose.material.icons.rounded.MoreVert import androidx.compose.material.icons.rounded.Person import androidx.compose.material.icons.rounded.PersonAdd +import androidx.compose.material.icons.rounded.SimCard import androidx.compose.material.icons.rounded.Unarchive import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem @@ -39,6 +40,7 @@ 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.vector.ImageVector import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource @@ -52,8 +54,11 @@ import com.android.messaging.ui.conversation.v2.CONVERSATION_ARCHIVE_BUTTON_TEST import com.android.messaging.ui.conversation.v2.CONVERSATION_CALL_BUTTON_TEST_TAG import com.android.messaging.ui.conversation.v2.CONVERSATION_DELETE_CONVERSATION_BUTTON_TEST_TAG import com.android.messaging.ui.conversation.v2.CONVERSATION_OVERFLOW_BUTTON_TEST_TAG +import com.android.messaging.ui.conversation.v2.CONVERSATION_SIM_SELECTOR_MENU_ITEM_TEST_TAG import com.android.messaging.ui.conversation.v2.CONVERSATION_UNARCHIVE_BUTTON_TEST_TAG +import com.android.messaging.ui.conversation.v2.composer.model.ConversationSimSelectorUiState import com.android.messaging.ui.conversation.v2.metadata.model.ConversationMetadataUiState +import com.android.messaging.ui.conversation.v2.resolveDisplayName private val CONVERSATION_TOP_APP_BAR_TITLE_SPACING = 12.dp private val CONVERSATION_TOP_APP_BAR_AVATAR_SIZE = 36.dp @@ -70,12 +75,14 @@ internal fun ConversationTopAppBar( isUnarchiveVisible: Boolean = false, isAddContactVisible: Boolean = false, isDeleteConversationVisible: Boolean = false, + simSelector: ConversationSimSelectorUiState = ConversationSimSelectorUiState(), onAddPeopleClick: () -> Unit, onCallClick: () -> Unit = {}, onArchiveClick: () -> Unit = {}, onUnarchiveClick: () -> Unit = {}, onAddContactClick: () -> Unit = {}, onDeleteConversationClick: () -> Unit = {}, + onSimSelectorClick: () -> Unit = {}, onTitleClick: () -> Unit, onNavigateBack: () -> Unit, ) { @@ -105,11 +112,15 @@ internal fun ConversationTopAppBar( onCallClick = onCallClick, ) } + val isSimSelectorVisible = simSelector.isAvailable + val isOverflowVisible = isAddPeopleVisible || isArchiveVisible || isUnarchiveVisible || isAddContactVisible || - isDeleteConversationVisible + isDeleteConversationVisible || + isSimSelectorVisible + if (isOverflowVisible) { ConversationTopAppBarOverflowMenu( isAddPeopleVisible = isAddPeopleVisible, @@ -117,11 +128,17 @@ internal fun ConversationTopAppBar( isUnarchiveVisible = isUnarchiveVisible, isAddContactVisible = isAddContactVisible, isDeleteConversationVisible = isDeleteConversationVisible, + isSimSelectorVisible = isSimSelectorVisible, + simSelectorLabel = simSelector.selectedSubscription + ?.label + ?.resolveDisplayName() + .orEmpty(), onAddPeopleClick = onAddPeopleClick, onArchiveClick = onArchiveClick, onUnarchiveClick = onUnarchiveClick, onAddContactClick = onAddContactClick, onDeleteConversationClick = onDeleteConversationClick, + onSimSelectorClick = onSimSelectorClick, ) } }, @@ -256,11 +273,14 @@ private fun ConversationTopAppBarOverflowMenu( isUnarchiveVisible: Boolean, isAddContactVisible: Boolean, isDeleteConversationVisible: Boolean, + isSimSelectorVisible: Boolean, + simSelectorLabel: String, onAddPeopleClick: () -> Unit, onArchiveClick: () -> Unit, onUnarchiveClick: () -> Unit, onAddContactClick: () -> Unit, onDeleteConversationClick: () -> Unit, + onSimSelectorClick: () -> Unit, ) { var isExpanded by remember { mutableStateOf(value = false) } @@ -287,88 +307,83 @@ private fun ConversationTopAppBarOverflowMenu( action() } - if (isAddPeopleVisible) { - DropdownMenuItem( - modifier = Modifier.testTag(CONVERSATION_ADD_PEOPLE_BUTTON_TEST_TAG), - text = { - Text(text = stringResource(id = R.string.conversation_add_people)) - }, - leadingIcon = { - Icon( - imageVector = Icons.Rounded.GroupAdd, - contentDescription = null, - ) - }, - onClick = { dismissAndInvoke(onAddPeopleClick) }, - ) - } + ConversationTopAppBarOverflowMenuItem( + isVisible = isSimSelectorVisible, + testTag = CONVERSATION_SIM_SELECTOR_MENU_ITEM_TEST_TAG, + label = simSelectorLabel, + icon = Icons.Rounded.SimCard, + onClick = { dismissAndInvoke(onSimSelectorClick) }, + ) - if (isAddContactVisible) { - DropdownMenuItem( - modifier = Modifier.testTag(CONVERSATION_ADD_CONTACT_BUTTON_TEST_TAG), - text = { - Text(text = stringResource(id = R.string.action_add_contact)) - }, - leadingIcon = { - Icon( - imageVector = Icons.Rounded.PersonAdd, - contentDescription = null, - ) - }, - onClick = { dismissAndInvoke(onAddContactClick) }, - ) - } + ConversationTopAppBarOverflowMenuItem( + isVisible = isAddPeopleVisible, + testTag = CONVERSATION_ADD_PEOPLE_BUTTON_TEST_TAG, + label = stringResource(id = R.string.conversation_add_people), + icon = Icons.Rounded.GroupAdd, + onClick = { dismissAndInvoke(onAddPeopleClick) }, + ) - if (isArchiveVisible) { - DropdownMenuItem( - modifier = Modifier.testTag(CONVERSATION_ARCHIVE_BUTTON_TEST_TAG), - text = { - Text(text = stringResource(id = R.string.action_archive)) - }, - leadingIcon = { - Icon( - imageVector = Icons.Rounded.Archive, - contentDescription = null, - ) - }, - onClick = { dismissAndInvoke(onArchiveClick) }, - ) - } + ConversationTopAppBarOverflowMenuItem( + isVisible = isAddContactVisible, + testTag = CONVERSATION_ADD_CONTACT_BUTTON_TEST_TAG, + label = stringResource(id = R.string.action_add_contact), + icon = Icons.Rounded.PersonAdd, + onClick = { dismissAndInvoke(onAddContactClick) }, + ) - if (isUnarchiveVisible) { - DropdownMenuItem( - modifier = Modifier.testTag(CONVERSATION_UNARCHIVE_BUTTON_TEST_TAG), - text = { - Text(text = stringResource(id = R.string.action_unarchive)) - }, - leadingIcon = { - Icon( - imageVector = Icons.Rounded.Unarchive, - contentDescription = null, - ) - }, - onClick = { dismissAndInvoke(onUnarchiveClick) }, - ) - } + ConversationTopAppBarOverflowMenuItem( + isVisible = isArchiveVisible, + testTag = CONVERSATION_ARCHIVE_BUTTON_TEST_TAG, + label = stringResource(id = R.string.action_archive), + icon = Icons.Rounded.Archive, + onClick = { dismissAndInvoke(onArchiveClick) }, + ) - if (isDeleteConversationVisible) { - DropdownMenuItem( - modifier = Modifier.testTag(CONVERSATION_DELETE_CONVERSATION_BUTTON_TEST_TAG), - text = { - Text(text = stringResource(id = R.string.action_delete)) - }, - leadingIcon = { - Icon( - imageVector = Icons.Rounded.Delete, - contentDescription = null, - ) - }, - onClick = { dismissAndInvoke(onDeleteConversationClick) }, - ) - } + ConversationTopAppBarOverflowMenuItem( + isVisible = isUnarchiveVisible, + testTag = CONVERSATION_UNARCHIVE_BUTTON_TEST_TAG, + label = stringResource(id = R.string.action_unarchive), + icon = Icons.Rounded.Unarchive, + onClick = { dismissAndInvoke(onUnarchiveClick) }, + ) + + ConversationTopAppBarOverflowMenuItem( + isVisible = isDeleteConversationVisible, + testTag = CONVERSATION_DELETE_CONVERSATION_BUTTON_TEST_TAG, + label = stringResource(id = R.string.action_delete), + icon = Icons.Rounded.Delete, + onClick = { dismissAndInvoke(onDeleteConversationClick) }, + ) } } +@Composable +private fun ConversationTopAppBarOverflowMenuItem( + isVisible: Boolean, + testTag: String, + label: String, + icon: ImageVector, + onClick: () -> Unit, +) { + if (!isVisible) { + return + } + + DropdownMenuItem( + modifier = Modifier.testTag(tag = testTag), + text = { + Text(text = label) + }, + leadingIcon = { + Icon( + imageVector = icon, + contentDescription = null, + ) + }, + onClick = onClick, + ) +} + @Composable private fun ConversationAvatar( isGroupConversation: Boolean, diff --git a/src/com/android/messaging/ui/conversation/v2/screen/ConversationScreen.kt b/src/com/android/messaging/ui/conversation/v2/screen/ConversationScreen.kt index 135e02ee..57280b10 100644 --- a/src/com/android/messaging/ui/conversation/v2/screen/ConversationScreen.kt +++ b/src/com/android/messaging/ui/conversation/v2/screen/ConversationScreen.kt @@ -37,6 +37,7 @@ import com.android.messaging.data.conversation.model.draft.ConversationDraft import com.android.messaging.ui.conversation.v2.CONVERSATION_LOADING_INDICATOR_TEST_TAG import com.android.messaging.ui.conversation.v2.composer.model.ConversationComposerAttachmentUiState import com.android.messaging.ui.conversation.v2.composer.ui.ConversationComposerSection +import com.android.messaging.ui.conversation.v2.composer.ui.ConversationSimSelectorSheet import com.android.messaging.ui.conversation.v2.entry.model.ConversationEntryStartupAttachment import com.android.messaging.ui.conversation.v2.mediapicker.ConversationMediaPickerOverlay import com.android.messaging.ui.conversation.v2.mediapicker.rememberConversationMediaPickerState @@ -166,6 +167,7 @@ internal fun ConversationScreen( onResolvedAttachmentClick = screenModel::onAttachmentClicked, onResolvedAttachmentRemove = screenModel::onRemoveResolvedAttachment, onSendClick = screenModel::onSendClick, + onSimSelected = screenModel::onSimSelected, onAttachmentClick = screenModel::onMessageAttachmentClicked, onExternalUriClick = screenModel::onExternalUriClicked, ) @@ -219,9 +221,19 @@ private fun ConversationScreenScaffold( onResolvedAttachmentClick: (ConversationComposerAttachmentUiState.Resolved) -> Unit, onResolvedAttachmentRemove: (String) -> Unit, onSendClick: () -> Unit, + onSimSelected: (String) -> Unit, onAttachmentClick: (contentType: String, contentUri: String) -> Unit, onExternalUriClick: (String) -> Unit, ) { + var isSimSheetVisible by rememberSaveable { mutableStateOf(value = false) } + + val hasSimSelector = uiState.composer.simSelector.isAvailable + LaunchedEffect(hasSimSelector) { + if (!hasSimSelector) { + isSimSheetVisible = false + } + } + Scaffold( modifier = modifier, topBar = { @@ -243,12 +255,14 @@ private fun ConversationScreenScaffold( isUnarchiveVisible = uiState.canUnarchive, isAddContactVisible = uiState.canAddContact, isDeleteConversationVisible = uiState.canDeleteConversation, + simSelector = uiState.composer.simSelector, onAddPeopleClick = onAddPeopleClick, onCallClick = onCallClick, onArchiveClick = onArchiveConversationClick, onUnarchiveClick = onUnarchiveConversationClick, onAddContactClick = onAddContactClick, onDeleteConversationClick = onDeleteConversationClick, + onSimSelectorClick = { isSimSheetVisible = true }, onTitleClick = onConversationDetailsClick, onNavigateBack = onNavigateBack, ) @@ -300,6 +314,21 @@ private fun ConversationScreenScaffold( onDismiss = onDeleteConversationDismissed, ) } + + if (isSimSheetVisible && hasSimSelector) { + ConversationSimSelectorSheet( + uiState = uiState.composer.simSelector, + onSimSelected = { selfParticipantId -> + onSimSelected(selfParticipantId) + @Suppress("AssignedValueIsNeverRead") + isSimSheetVisible = false + }, + onDismissRequest = { + @Suppress("AssignedValueIsNeverRead") + isSimSheetVisible = false + }, + ) + } } @Composable diff --git a/src/com/android/messaging/ui/conversation/v2/screen/ConversationViewModel.kt b/src/com/android/messaging/ui/conversation/v2/screen/ConversationViewModel.kt index c24f8a02..5aa49c53 100644 --- a/src/com/android/messaging/ui/conversation/v2/screen/ConversationViewModel.kt +++ b/src/com/android/messaging/ui/conversation/v2/screen/ConversationViewModel.kt @@ -4,6 +4,7 @@ import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.android.messaging.data.conversation.model.draft.ConversationDraft +import com.android.messaging.data.conversation.repository.ConversationSubscriptionsRepository import com.android.messaging.data.media.model.ConversationMediaItem import com.android.messaging.datamodel.MessagingContentProvider import com.android.messaging.di.core.DefaultDispatcher @@ -28,6 +29,7 @@ import com.android.messaging.ui.conversation.v2.screen.model.ConversationScreenE import com.android.messaging.ui.conversation.v2.screen.model.ConversationScreenScaffoldUiState import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject +import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow @@ -69,6 +71,8 @@ internal interface ConversationScreenModel { fun onCallClick() + fun onSimSelected(selfParticipantId: String) + fun onExternalUriClicked(uri: String) fun onGalleryMediaConfirmed(mediaItems: List) @@ -104,6 +108,7 @@ internal class ConversationViewModel @Inject constructor( private val conversationMediaPickerDelegate: ConversationMediaPickerDelegate, private val conversationMetadataDelegate: ConversationMetadataDelegate, private val conversationComposerUiStateMapper: ConversationComposerUiStateMapper, + private val conversationSubscriptionsRepository: ConversationSubscriptionsRepository, private val canAddMoreConversationParticipants: CanAddMoreConversationParticipants, private val isDeviceVoiceCapable: IsDeviceVoiceCapable, @param:DefaultDispatcher @@ -122,13 +127,25 @@ internal class ConversationViewModel @Inject constructor( override val effects = _effects.asSharedFlow() + private val subscriptionsFlow = conversationSubscriptionsRepository + .observeActiveSubscriptions() + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed( + stopTimeoutMillis = STATEFLOW_STOP_TIMEOUT_MILLIS, + ), + initialValue = persistentListOf(), + ) + private val composerUiState = combine( conversationMetadataDelegate.state, conversationDraftDelegate.state, - ) { metadataState, draftState -> + subscriptionsFlow, + ) { metadataState, draftState, subscriptions -> conversationComposerUiStateMapper.map( draftState = draftState, composerAvailability = metadataState.composerAvailability, + subscriptions = subscriptions, ) }.stateIn( scope = viewModelScope, @@ -138,6 +155,7 @@ internal class ConversationViewModel @Inject constructor( initialValue = conversationComposerUiStateMapper.map( draftState = conversationDraftDelegate.state.value, composerAvailability = conversationMetadataDelegate.state.value.composerAvailability, + subscriptions = subscriptionsFlow.value, ), ) @@ -406,6 +424,12 @@ internal class ConversationViewModel @Inject constructor( } } + override fun onSimSelected(selfParticipantId: String) { + conversationDraftDelegate.onSelfParticipantIdChanged( + selfParticipantId = selfParticipantId, + ) + } + override fun onExternalUriClicked(uri: String) { viewModelScope.launch(defaultDispatcher) { _effects.emit( From 413dbc4c01e56bdf3570da63411d70fdc4c6c0ff Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Mon, 20 Apr 2026 22:52:02 +0300 Subject: [PATCH 47/99] Display avatars in conversation top bar --- .../model/metadata/ConversationMetadata.kt | 1 + .../repository/ConversationsRepository.kt | 25 +++++- .../ConversationMetadataUiStateMapper.kt | 12 ++- .../model/ConversationMetadataUiState.kt | 13 ++- .../v2/metadata/ui/ConversationTopAppBar.kt | 80 ++++++++++++++++--- .../v2/screen/ConversationViewModel.kt | 17 ++-- 6 files changed, 127 insertions(+), 21 deletions(-) diff --git a/src/com/android/messaging/data/conversation/model/metadata/ConversationMetadata.kt b/src/com/android/messaging/data/conversation/model/metadata/ConversationMetadata.kt index 622ff945..2617a783 100644 --- a/src/com/android/messaging/data/conversation/model/metadata/ConversationMetadata.kt +++ b/src/com/android/messaging/data/conversation/model/metadata/ConversationMetadata.kt @@ -7,6 +7,7 @@ internal data class ConversationMetadata( val participantCount: Int, val otherParticipantNormalizedDestination: String?, val otherParticipantContactLookupKey: String?, + val otherParticipantPhotoUri: String?, val isArchived: Boolean, val composerAvailability: ConversationComposerAvailability, ) diff --git a/src/com/android/messaging/data/conversation/repository/ConversationsRepository.kt b/src/com/android/messaging/data/conversation/repository/ConversationsRepository.kt index ed521f9a..e2811368 100644 --- a/src/com/android/messaging/data/conversation/repository/ConversationsRepository.kt +++ b/src/com/android/messaging/data/conversation/repository/ConversationsRepository.kt @@ -217,13 +217,20 @@ internal class ConversationsRepositoryImpl @Inject constructor( return@use null } + val participantCount = cursor.getInt(ConversationColumns.PARTICIPANT_COUNT) + + val otherParticipantPhotoUri = when { + participantCount == 1 -> queryConversationParticipantPhotoUri(uri = uri) + else -> null + } + ConversationMetadata( conversationName = cursor.getStringOrEmpty(ConversationColumns.NAME), selfParticipantId = cursor.getStringOrEmpty( ConversationColumns.CURRENT_SELF_ID, ), - isGroupConversation = cursor.getInt(ConversationColumns.PARTICIPANT_COUNT) > 1, - participantCount = cursor.getInt(ConversationColumns.PARTICIPANT_COUNT), + isGroupConversation = participantCount > 1, + participantCount = participantCount, otherParticipantNormalizedDestination = cursor .getStringOrEmpty( ConversationColumns.OTHER_PARTICIPANT_NORMALIZED_DESTINATION, @@ -232,12 +239,26 @@ internal class ConversationsRepositoryImpl @Inject constructor( otherParticipantContactLookupKey = cursor .getStringOrEmpty(ConversationColumns.PARTICIPANT_LOOKUP_KEY) .takeIf { it.isNotBlank() }, + otherParticipantPhotoUri = otherParticipantPhotoUri, isArchived = cursor.getInt(ConversationColumns.ARCHIVE_STATUS) == 1, composerAvailability = ConversationComposerAvailability.editable(), ) } } + private fun queryConversationParticipantPhotoUri(uri: Uri): String? { + val conversationId = uri.lastPathSegment + ?.takeIf { it.isNotBlank() } + ?: return null + + val participants = queryConversationParticipants(conversationId = conversationId) + val otherParticipant = participants.getOtherParticipant() + + return otherParticipant + ?.profilePhotoUri + ?.takeIf { it.isNotBlank() } + } + private fun queryConversationParticipants( conversationId: String, ): ConversationParticipantsData { diff --git a/src/com/android/messaging/ui/conversation/v2/metadata/mapper/ConversationMetadataUiStateMapper.kt b/src/com/android/messaging/ui/conversation/v2/metadata/mapper/ConversationMetadataUiStateMapper.kt index a1a04baf..a5187655 100644 --- a/src/com/android/messaging/ui/conversation/v2/metadata/mapper/ConversationMetadataUiStateMapper.kt +++ b/src/com/android/messaging/ui/conversation/v2/metadata/mapper/ConversationMetadataUiStateMapper.kt @@ -13,10 +13,20 @@ internal class ConversationMetadataUiStateMapperImpl @Inject constructor() : ConversationMetadataUiStateMapper { override fun map(metadata: ConversationMetadata): ConversationMetadataUiState { + val avatar = when { + metadata.isGroupConversation -> ConversationMetadataUiState.Avatar.Group + + else -> { + ConversationMetadataUiState.Avatar.Single( + photoUri = metadata.otherParticipantPhotoUri, + ) + } + } + return ConversationMetadataUiState.Present( title = metadata.conversationName, selfParticipantId = metadata.selfParticipantId, - isGroupConversation = metadata.isGroupConversation, + avatar = avatar, participantCount = metadata.participantCount, otherParticipantPhoneNumber = metadata .otherParticipantNormalizedDestination diff --git a/src/com/android/messaging/ui/conversation/v2/metadata/model/ConversationMetadataUiState.kt b/src/com/android/messaging/ui/conversation/v2/metadata/model/ConversationMetadataUiState.kt index c213f43c..22d4f460 100644 --- a/src/com/android/messaging/ui/conversation/v2/metadata/model/ConversationMetadataUiState.kt +++ b/src/com/android/messaging/ui/conversation/v2/metadata/model/ConversationMetadataUiState.kt @@ -8,6 +8,17 @@ import com.android.messaging.data.conversation.model.metadata.ConversationCompos internal sealed interface ConversationMetadataUiState { val composerAvailability: ConversationComposerAvailability + @Immutable + sealed interface Avatar { + @Immutable + data object Group : Avatar + + @Immutable + data class Single( + val photoUri: String?, + ) : Avatar + } + @Immutable data object Loading : ConversationMetadataUiState { override val composerAvailability = ConversationComposerAvailability.unavailable( @@ -19,7 +30,7 @@ internal sealed interface ConversationMetadataUiState { data class Present( val title: String, val selfParticipantId: String, - val isGroupConversation: Boolean, + val avatar: Avatar, val participantCount: Int, val otherParticipantPhoneNumber: String?, val otherParticipantContactLookupKey: String?, diff --git a/src/com/android/messaging/ui/conversation/v2/metadata/ui/ConversationTopAppBar.kt b/src/com/android/messaging/ui/conversation/v2/metadata/ui/ConversationTopAppBar.kt index 695ebd0a..959c0624 100644 --- a/src/com/android/messaging/ui/conversation/v2/metadata/ui/ConversationTopAppBar.kt +++ b/src/com/android/messaging/ui/conversation/v2/metadata/ui/ConversationTopAppBar.kt @@ -40,13 +40,16 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage import com.android.messaging.R import com.android.messaging.ui.conversation.v2.CONVERSATION_ADD_CONTACT_BUTTON_TEST_TAG import com.android.messaging.ui.conversation.v2.CONVERSATION_ADD_PEOPLE_BUTTON_TEST_TAG @@ -165,7 +168,7 @@ private fun rememberConversationTopAppBarPresentation( val subtitle = conversationSubtitle( metadata = metadata, ) - val isGroupConversation = conversationIsGroup( + val avatar = conversationAvatar( metadata = metadata, ) @@ -173,12 +176,12 @@ private fun rememberConversationTopAppBarPresentation( metadata, title, subtitle, - isGroupConversation, + avatar, ) { ConversationTopAppBarPresentation( title = title, subtitle = subtitle, - isGroupConversation = isGroupConversation, + avatar = avatar, ) } } @@ -200,7 +203,7 @@ private fun ConversationTopAppBarTitle( verticalAlignment = Alignment.CenterVertically, ) { ConversationAvatar( - isGroupConversation = presentation.isGroupConversation, + avatar = presentation.avatar, ) ConversationTopAppBarText( @@ -386,7 +389,41 @@ private fun ConversationTopAppBarOverflowMenuItem( @Composable private fun ConversationAvatar( - isGroupConversation: Boolean, + avatar: ConversationMetadataUiState.Avatar, +) { + when (avatar) { + ConversationMetadataUiState.Avatar.Group -> { + ConversationAvatarFallback( + icon = Icons.Rounded.Group, + ) + } + + is ConversationMetadataUiState.Avatar.Single -> { + when { + avatar.photoUri.isNullOrBlank() -> { + ConversationAvatarFallback( + icon = Icons.Rounded.Person, + ) + } + + else -> { + AsyncImage( + model = avatar.photoUri, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .size(size = CONVERSATION_TOP_APP_BAR_AVATAR_SIZE) + .clip(shape = CircleShape), + ) + } + } + } + } +} + +@Composable +private fun ConversationAvatarFallback( + icon: ImageVector, ) { Surface( color = MaterialTheme.colorScheme.secondaryContainer, @@ -399,10 +436,7 @@ private fun ConversationAvatar( contentAlignment = Alignment.Center, ) { Icon( - imageVector = when { - isGroupConversation -> Icons.Rounded.Group - else -> Icons.Rounded.Person - }, + imageVector = icon, contentDescription = null, modifier = Modifier.size(size = CONVERSATION_TOP_APP_BAR_AVATAR_ICON_SIZE), ) @@ -410,6 +444,26 @@ private fun ConversationAvatar( } } +private fun conversationAvatar( + metadata: ConversationMetadataUiState, +): ConversationMetadataUiState.Avatar { + return when (metadata) { + ConversationMetadataUiState.Loading -> { + ConversationMetadataUiState.Avatar.Single( + photoUri = null, + ) + } + + ConversationMetadataUiState.Unavailable -> { + ConversationMetadataUiState.Avatar.Single( + photoUri = null, + ) + } + + is ConversationMetadataUiState.Present -> metadata.avatar + } +} + @Composable private fun conversationTitle( metadata: ConversationMetadataUiState, @@ -434,7 +488,9 @@ private fun conversationIsGroup( return when (metadata) { ConversationMetadataUiState.Loading -> false ConversationMetadataUiState.Unavailable -> false - is ConversationMetadataUiState.Present -> metadata.isGroupConversation + is ConversationMetadataUiState.Present -> { + metadata.avatar is ConversationMetadataUiState.Avatar.Group + } } } @@ -449,7 +505,7 @@ private fun conversationSubtitle( is ConversationMetadataUiState.Present -> { when { - metadata.isGroupConversation && metadata.participantCount > 1 -> { + metadata.participantCount > 1 -> { pluralStringResource( id = R.plurals.wearable_participant_count, count = metadata.participantCount, @@ -467,5 +523,5 @@ private fun conversationSubtitle( private data class ConversationTopAppBarPresentation( val title: String, val subtitle: String?, - val isGroupConversation: Boolean, + val avatar: ConversationMetadataUiState.Avatar, ) diff --git a/src/com/android/messaging/ui/conversation/v2/screen/ConversationViewModel.kt b/src/com/android/messaging/ui/conversation/v2/screen/ConversationViewModel.kt index 5aa49c53..6aa6f1d2 100644 --- a/src/com/android/messaging/ui/conversation/v2/screen/ConversationViewModel.kt +++ b/src/com/android/messaging/ui/conversation/v2/screen/ConversationViewModel.kt @@ -311,18 +311,25 @@ internal class ConversationViewModel @Inject constructor( metadataState: ConversationMetadataUiState, ): Boolean { val isOneOnOne = metadataState is ConversationMetadataUiState.Present && - !metadataState.isGroupConversation && + metadataState.participantCount == 1 && metadataState.otherParticipantPhoneNumber != null + return isOneOnOne && isDeviceVoiceCapable() } private fun canAddContact( metadataState: ConversationMetadataUiState, ): Boolean { - val present = metadataState as? ConversationMetadataUiState.Present ?: return false - val hasDestination = !present.otherParticipantPhoneNumber.isNullOrBlank() - val hasContactLink = !present.otherParticipantContactLookupKey.isNullOrBlank() - return !present.isGroupConversation && hasDestination && !hasContactLink + if (metadataState !is ConversationMetadataUiState.Present) { + return false + } + + val hasDestination = !metadataState.otherParticipantPhoneNumber.isNullOrBlank() + val hasContactLink = !metadataState.otherParticipantContactLookupKey.isNullOrBlank() + + return metadataState.participantCount == 1 && + hasDestination && + !hasContactLink } override fun onSeedDraft( From 7de46b6b467ec4f4b3490a8ad81b86ca22b3404c Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Mon, 20 Apr 2026 23:13:43 +0300 Subject: [PATCH 48/99] Ignore .kotlin --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index d92cac30..1ad93685 100644 --- a/.gitignore +++ b/.gitignore @@ -13,4 +13,6 @@ keystore.properties local.properties /lib/build +.kotlin + *.log From 5eddd41b5886687f7e8cdcc9cb38d94156b21a97 Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Mon, 20 Apr 2026 23:17:35 +0300 Subject: [PATCH 49/99] Show 1:1 phone-number subtitle for conversation --- .../model/metadata/ConversationMetadata.kt | 1 + .../repository/ConversationsRepository.kt | 30 ++++---- .../ConversationMetadataUiStateMapper.kt | 1 + .../model/ConversationMetadataUiState.kt | 1 + .../v2/metadata/ui/ConversationTopAppBar.kt | 69 +++++++++++++++++-- 5 files changed, 83 insertions(+), 19 deletions(-) diff --git a/src/com/android/messaging/data/conversation/model/metadata/ConversationMetadata.kt b/src/com/android/messaging/data/conversation/model/metadata/ConversationMetadata.kt index 2617a783..8a7bba93 100644 --- a/src/com/android/messaging/data/conversation/model/metadata/ConversationMetadata.kt +++ b/src/com/android/messaging/data/conversation/model/metadata/ConversationMetadata.kt @@ -5,6 +5,7 @@ internal data class ConversationMetadata( val selfParticipantId: String, val isGroupConversation: Boolean, val participantCount: Int, + val otherParticipantDisplayDestination: String?, val otherParticipantNormalizedDestination: String?, val otherParticipantContactLookupKey: String?, val otherParticipantPhotoUri: String?, diff --git a/src/com/android/messaging/data/conversation/repository/ConversationsRepository.kt b/src/com/android/messaging/data/conversation/repository/ConversationsRepository.kt index e2811368..abc51e36 100644 --- a/src/com/android/messaging/data/conversation/repository/ConversationsRepository.kt +++ b/src/com/android/messaging/data/conversation/repository/ConversationsRepository.kt @@ -219,11 +219,18 @@ internal class ConversationsRepositoryImpl @Inject constructor( val participantCount = cursor.getInt(ConversationColumns.PARTICIPANT_COUNT) - val otherParticipantPhotoUri = when { - participantCount == 1 -> queryConversationParticipantPhotoUri(uri = uri) + val otherParticipant = when { + participantCount == 1 -> queryConversationOtherParticipant(uri = uri) else -> null } + val otherParticipantContactLookupKey = otherParticipant + ?.lookupKey + ?.takeIf { it.isNotBlank() } + ?: cursor + .getStringOrEmpty(ConversationColumns.PARTICIPANT_LOOKUP_KEY) + .takeIf { it.isNotBlank() } + ConversationMetadata( conversationName = cursor.getStringOrEmpty(ConversationColumns.NAME), selfParticipantId = cursor.getStringOrEmpty( @@ -231,32 +238,31 @@ internal class ConversationsRepositoryImpl @Inject constructor( ), isGroupConversation = participantCount > 1, participantCount = participantCount, + otherParticipantDisplayDestination = otherParticipant + ?.displayDestination + ?.takeIf { it.isNotBlank() }, otherParticipantNormalizedDestination = cursor .getStringOrEmpty( ConversationColumns.OTHER_PARTICIPANT_NORMALIZED_DESTINATION, ) .takeIf { it.isNotBlank() }, - otherParticipantContactLookupKey = cursor - .getStringOrEmpty(ConversationColumns.PARTICIPANT_LOOKUP_KEY) - .takeIf { it.isNotBlank() }, - otherParticipantPhotoUri = otherParticipantPhotoUri, + otherParticipantContactLookupKey = otherParticipantContactLookupKey, + otherParticipantPhotoUri = otherParticipant + ?.profilePhotoUri + ?.takeIf { it.isNotBlank() }, isArchived = cursor.getInt(ConversationColumns.ARCHIVE_STATUS) == 1, composerAvailability = ConversationComposerAvailability.editable(), ) } } - private fun queryConversationParticipantPhotoUri(uri: Uri): String? { + private fun queryConversationOtherParticipant(uri: Uri): ParticipantData? { val conversationId = uri.lastPathSegment ?.takeIf { it.isNotBlank() } ?: return null val participants = queryConversationParticipants(conversationId = conversationId) - val otherParticipant = participants.getOtherParticipant() - - return otherParticipant - ?.profilePhotoUri - ?.takeIf { it.isNotBlank() } + return participants.getOtherParticipant() } private fun queryConversationParticipants( diff --git a/src/com/android/messaging/ui/conversation/v2/metadata/mapper/ConversationMetadataUiStateMapper.kt b/src/com/android/messaging/ui/conversation/v2/metadata/mapper/ConversationMetadataUiStateMapper.kt index a5187655..ab712ca6 100644 --- a/src/com/android/messaging/ui/conversation/v2/metadata/mapper/ConversationMetadataUiStateMapper.kt +++ b/src/com/android/messaging/ui/conversation/v2/metadata/mapper/ConversationMetadataUiStateMapper.kt @@ -28,6 +28,7 @@ internal class ConversationMetadataUiStateMapperImpl @Inject constructor() : selfParticipantId = metadata.selfParticipantId, avatar = avatar, participantCount = metadata.participantCount, + otherParticipantDisplayDestination = metadata.otherParticipantDisplayDestination, otherParticipantPhoneNumber = metadata .otherParticipantNormalizedDestination ?.takeIf(MmsSmsUtils::isPhoneNumber), diff --git a/src/com/android/messaging/ui/conversation/v2/metadata/model/ConversationMetadataUiState.kt b/src/com/android/messaging/ui/conversation/v2/metadata/model/ConversationMetadataUiState.kt index 22d4f460..e5469ef1 100644 --- a/src/com/android/messaging/ui/conversation/v2/metadata/model/ConversationMetadataUiState.kt +++ b/src/com/android/messaging/ui/conversation/v2/metadata/model/ConversationMetadataUiState.kt @@ -32,6 +32,7 @@ internal sealed interface ConversationMetadataUiState { val selfParticipantId: String, val avatar: Avatar, val participantCount: Int, + val otherParticipantDisplayDestination: String?, val otherParticipantPhoneNumber: String?, val otherParticipantContactLookupKey: String?, val isArchived: Boolean, diff --git a/src/com/android/messaging/ui/conversation/v2/metadata/ui/ConversationTopAppBar.kt b/src/com/android/messaging/ui/conversation/v2/metadata/ui/ConversationTopAppBar.kt index 959c0624..1375736d 100644 --- a/src/com/android/messaging/ui/conversation/v2/metadata/ui/ConversationTopAppBar.kt +++ b/src/com/android/messaging/ui/conversation/v2/metadata/ui/ConversationTopAppBar.kt @@ -43,12 +43,17 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.core.text.BidiFormatter +import androidx.core.text.TextDirectionHeuristicsCompat import coil3.compose.AsyncImage import com.android.messaging.R import com.android.messaging.ui.conversation.v2.CONVERSATION_ADD_CONTACT_BUTTON_TEST_TAG @@ -62,6 +67,7 @@ import com.android.messaging.ui.conversation.v2.CONVERSATION_UNARCHIVE_BUTTON_TE import com.android.messaging.ui.conversation.v2.composer.model.ConversationSimSelectorUiState import com.android.messaging.ui.conversation.v2.metadata.model.ConversationMetadataUiState import com.android.messaging.ui.conversation.v2.resolveDisplayName +import com.android.messaging.util.AccessibilityUtil private val CONVERSATION_TOP_APP_BAR_TITLE_SPACING = 12.dp private val CONVERSATION_TOP_APP_BAR_AVATAR_SIZE = 36.dp @@ -162,25 +168,25 @@ private fun conversationTopAppBarColors(): TopAppBarColors { private fun rememberConversationTopAppBarPresentation( metadata: ConversationMetadataUiState, ): ConversationTopAppBarPresentation { - val title = conversationTitle( - metadata = metadata, - ) - val subtitle = conversationSubtitle( - metadata = metadata, - ) - val avatar = conversationAvatar( + val title = conversationTitle(metadata) + val subtitle = conversationSubtitle(metadata) + val subtitleContentDescription = conversationSubtitleContentDescription( metadata = metadata, ) + val avatar = conversationAvatar(metadata) + return remember( metadata, title, subtitle, + subtitleContentDescription, avatar, ) { ConversationTopAppBarPresentation( title = title, subtitle = subtitle, + subtitleContentDescription = subtitleContentDescription, avatar = avatar, ) } @@ -230,6 +236,11 @@ private fun ConversationTopAppBarText( if (presentation.subtitle != null) { Text( + modifier = Modifier.semantics { + presentation.subtitleContentDescription?.let { subtitleContentDescription -> + contentDescription = subtitleContentDescription + } + }, text = presentation.subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, @@ -505,6 +516,15 @@ private fun conversationSubtitle( is ConversationMetadataUiState.Present -> { when { + shouldShowOneOnOneSubtitle(metadata = metadata) -> { + BidiFormatter + .getInstance() + .unicodeWrap( + metadata.otherParticipantDisplayDestination, + TextDirectionHeuristicsCompat.LTR, + ) + } + metadata.participantCount > 1 -> { pluralStringResource( id = R.plurals.wearable_participant_count, @@ -519,9 +539,44 @@ private fun conversationSubtitle( } } +@Composable +private fun conversationSubtitleContentDescription( + metadata: ConversationMetadataUiState, +): String? { + return when (metadata) { + ConversationMetadataUiState.Loading -> null + ConversationMetadataUiState.Unavailable -> null + is ConversationMetadataUiState.Present -> { + metadata.otherParticipantDisplayDestination + ?.takeIf { + shouldShowOneOnOneSubtitle(metadata = metadata) && + metadata.otherParticipantPhoneNumber != null + } + ?.let { displayDestination -> + AccessibilityUtil.getVocalizedPhoneNumber( + LocalResources.current, + displayDestination, + ) + } + ?.takeIf { it.isNotBlank() } + } + } +} + +private fun shouldShowOneOnOneSubtitle( + metadata: ConversationMetadataUiState.Present, +): Boolean { + val displayDestination = metadata.otherParticipantDisplayDestination + ?.takeIf { it.isNotBlank() } + ?: return false + + return !displayDestination.equals(other = metadata.title, ignoreCase = false) +} + @Immutable private data class ConversationTopAppBarPresentation( val title: String, val subtitle: String?, + val subtitleContentDescription: String?, val avatar: ConversationMetadataUiState.Avatar, ) From a2c5d0004ebc5088d829f9ba7bfc430fdaf89a82 Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Tue, 21 Apr 2026 12:55:08 +0300 Subject: [PATCH 50/99] Add audio attachments UI and playback --- .../android/messaging/debug/TestDataSeeder.kt | 116 +++++- .../android/messaging/ui/UIIntentsImpl.java | 3 +- .../conversation/v2/ConversationTestTags.kt | 4 + .../ConversationInlineAttachment.kt | 1 + .../ConversationAttachmentSectionsBuilder.kt | 5 + .../ConversationGenericInlineAttachmentRow.kt | 134 +++++++ .../ConversationInlineAttachmentRow.kt | 131 +------ ...ationInlineAudioAttachmentPlaybackState.kt | 215 +++++++++++ .../ConversationInlineAudioAttachmentRow.kt | 343 ++++++++++++++++++ .../ConversationMessageAttachments.kt | 6 + .../ConversationVisualAttachments.kt | 2 +- .../ui/message/ConversationMessage.kt | 17 + .../ConversationMessageContentBuilder.kt | 4 - 13 files changed, 862 insertions(+), 119 deletions(-) create mode 100644 src/com/android/messaging/ui/conversation/v2/messages/ui/attachment/ConversationGenericInlineAttachmentRow.kt create mode 100644 src/com/android/messaging/ui/conversation/v2/messages/ui/attachment/ConversationInlineAudioAttachmentPlaybackState.kt create mode 100644 src/com/android/messaging/ui/conversation/v2/messages/ui/attachment/ConversationInlineAudioAttachmentRow.kt diff --git a/src/com/android/messaging/debug/TestDataSeeder.kt b/src/com/android/messaging/debug/TestDataSeeder.kt index 36928755..a49de6bd 100644 --- a/src/com/android/messaging/debug/TestDataSeeder.kt +++ b/src/com/android/messaging/debug/TestDataSeeder.kt @@ -27,8 +27,13 @@ import com.android.messaging.datamodel.data.ParticipantData import com.android.messaging.util.ContentType import com.android.messaging.util.LogUtil import com.android.messaging.util.db.ext.withTransaction +import java.io.BufferedOutputStream +import java.io.ByteArrayOutputStream +import java.io.DataOutputStream import java.io.File import java.io.FileOutputStream +import kotlin.math.PI +import kotlin.math.sin private const val TAG = "TestDataSeeder" private const val TEST_PHONE_PREFIX = "+15550" @@ -39,6 +44,10 @@ private const val SEED_IMAGE_2_FILE_ID = "800002" private const val SEED_IMAGE_3_FILE_ID = "800003" private const val SEED_VCARD_FILE_ID = "800004" private const val SEED_VIDEO_FILE_ID = "800005" +private const val SEED_AUDIO_FILE_ID = "800006" +private const val SEED_AUDIO_DURATION_SECONDS = 2 +private const val SEED_AUDIO_SAMPLE_RATE_HZ = 16_000 +private const val SEED_AUDIO_FREQUENCY_HZ = 440.0 private const val MINUTES = 60 * 1000L private const val HOURS = 60 * MINUTES @@ -55,6 +64,7 @@ fun seedTestData(context: Context) { } val testImages = buildTestImages(context) + val testAudio = buildTestAudio() val testVideo = buildTestVideo(context) val testVCard = buildTestVCard(context) val now = System.currentTimeMillis() @@ -78,7 +88,7 @@ fun seedTestData(context: Context) { seedScenarioE(db, selfId, grace, now) seedScenarioF(db, selfId, henry, now) seedScenarioG(db, selfId, iris, testImages, now) - seedScenarioH(db, selfId, jack, carol, testImages, testVideo, testVCard, now) + seedScenarioH(db, selfId, jack, carol, testImages, testAudio, testVideo, testVCard, now) seedScenarioI(db, selfId, carol, dave, eve, now) } @@ -165,6 +175,7 @@ fun clearSeededTestData(context: Context) { } File(context.cacheDir, "seed_video.mp4").delete() File(context.cacheDir, "seed_contact.vcf").delete() + deleteSeedScratchFile(fileId = SEED_AUDIO_FILE_ID, fileExtension = "wav") deleteSeedScratchFile(fileId = SEED_IMAGE_1_FILE_ID, fileExtension = "jpg") deleteSeedScratchFile(fileId = SEED_IMAGE_2_FILE_ID, fileExtension = "jpg") deleteSeedScratchFile(fileId = SEED_IMAGE_3_FILE_ID, fileExtension = "jpg") @@ -175,6 +186,7 @@ fun clearSeededTestData(context: Context) { deleteSeedScratchFile(fileId = SEED_IMAGE_3_FILE_ID) deleteSeedScratchFile(fileId = SEED_VCARD_FILE_ID) deleteSeedScratchFile(fileId = SEED_VIDEO_FILE_ID) + deleteSeedScratchFile(fileId = SEED_AUDIO_FILE_ID) deleteSeededAttachmentScratchFiles(attachmentUris = seededAttachmentUris) MessagingContentProvider.notifyConversationListChanged() @@ -251,6 +263,22 @@ private fun buildTestVCard(context: Context): String { return vCardUri.toString() } +private fun buildTestAudio(): String { + val audioUri = buildSeedScratchUri( + fileId = SEED_AUDIO_FILE_ID, + fileExtension = "wav", + ) + val file = MediaScratchFileProvider.getFileFromUri(audioUri) + file.parentFile?.mkdirs() + + BufferedOutputStream(FileOutputStream(file)).use { outputStream -> + writeSeedWaveFile(outputStream = outputStream) + } + + MediaScratchFileProvider.addUriToDisplayNameEntry(audioUri, "seed_audio.wav") + return audioUri.toString() +} + private fun buildTestVideo(context: Context): String { val videoUri = buildSeedScratchUri( fileId = SEED_VIDEO_FILE_ID, @@ -307,6 +335,54 @@ private fun deleteSeededAttachmentScratchFiles( } } +private fun writeSeedWaveFile( + outputStream: BufferedOutputStream, +) { + val pcmBytes = buildSeedAudioPcmData() + val channels = 1 + val bitsPerSample = 16 + val byteRate = SEED_AUDIO_SAMPLE_RATE_HZ * channels * bitsPerSample / 8 + val blockAlign = channels * bitsPerSample / 8 + val dataSize = pcmBytes.size + val riffChunkSize = 36 + dataSize + + DataOutputStream(outputStream).use { dataOutputStream -> + dataOutputStream.writeBytes("RIFF") + dataOutputStream.writeInt(Integer.reverseBytes(riffChunkSize)) + dataOutputStream.writeBytes("WAVE") + dataOutputStream.writeBytes("fmt ") + dataOutputStream.writeInt(Integer.reverseBytes(16)) + dataOutputStream.writeShort(java.lang.Short.reverseBytes(1.toShort()).toInt()) + dataOutputStream.writeShort(java.lang.Short.reverseBytes(channels.toShort()).toInt()) + dataOutputStream.writeInt(Integer.reverseBytes(SEED_AUDIO_SAMPLE_RATE_HZ)) + dataOutputStream.writeInt(Integer.reverseBytes(byteRate)) + dataOutputStream.writeShort(java.lang.Short.reverseBytes(blockAlign.toShort()).toInt()) + dataOutputStream.writeShort(java.lang.Short.reverseBytes(bitsPerSample.toShort()).toInt()) + dataOutputStream.writeBytes("data") + dataOutputStream.writeInt(Integer.reverseBytes(dataSize)) + dataOutputStream.write(pcmBytes) + } +} + +private fun buildSeedAudioPcmData(): ByteArray { + val totalSamples = SEED_AUDIO_SAMPLE_RATE_HZ * SEED_AUDIO_DURATION_SECONDS + val byteArrayOutputStream = ByteArrayOutputStream(totalSamples * 2) + val sampleAmplitude = Short.MAX_VALUE * 0.35 + + DataOutputStream(byteArrayOutputStream).use { dataOutputStream -> + repeat(totalSamples) { sampleIndex -> + val timeSeconds = sampleIndex.toDouble() / SEED_AUDIO_SAMPLE_RATE_HZ.toDouble() + val sampleValue = ( + sin(2.0 * PI * SEED_AUDIO_FREQUENCY_HZ * timeSeconds) * sampleAmplitude + ).toInt() + .toShort() + dataOutputStream.writeShort(java.lang.Short.reverseBytes(sampleValue).toInt()) + } + } + + return byteArrayOutputStream.toByteArray() +} + private data class SeedImageSpec( val fileId: String, val fileExtension: String, @@ -594,6 +670,31 @@ private fun insertVideoMessage( ) } +private fun insertAudioMessage( + db: DatabaseWrapper, + conversationId: Long, + senderId: String, + selfId: String, + audioUri: String, + status: Int, + timestamp: Long, + seen: Boolean = true, + read: Boolean = true, +): Long { + return insertAttachmentMessage( + db = db, + conversationId = conversationId, + senderId = senderId, + selfId = selfId, + contentType = ContentType.AUDIO_X_WAV, + attachmentUri = audioUri, + status = status, + timestamp = timestamp, + seen = seen, + read = read, + ) +} + private fun insertMessageRow( db: DatabaseWrapper, conversationId: Long, @@ -1054,6 +1155,7 @@ private fun seedScenarioH( jackId: String, carolId: String, images: List, + audioUri: String, videoUri: String, vCardUri: String, now: Long, @@ -1100,6 +1202,8 @@ private fun seedScenarioH( Msg("text", text = TEST_YOUTUBE_VIDEO_URL, senderId = carolId), Msg("text", text = "The clip version is even better", senderId = jackId), Msg("video", attachmentUri = videoUri, senderId = carolId), + Msg("text", text = "And here's the ambient audio from the room", senderId = jackId), + Msg("audio", attachmentUri = audioUri, senderId = jackId), Msg("text", text = "Send me the photographer contact too", senderId = selfId), Msg("vcard", attachmentUri = vCardUri, senderId = carolId), Msg("text", text = "One more", senderId = carolId), @@ -1159,6 +1263,16 @@ private fun seedScenarioH( timestamp = msgTime, ) + "audio" -> insertAudioMessage( + db = db, + conversationId = convId, + senderId = m.senderId, + selfId = selfId, + audioUri = m.attachmentUri, + status = status, + timestamp = msgTime, + ) + else -> insertTextMessage( db, convId, diff --git a/src/com/android/messaging/ui/UIIntentsImpl.java b/src/com/android/messaging/ui/UIIntentsImpl.java index 9c5d18f0..f1392d6d 100644 --- a/src/com/android/messaging/ui/UIIntentsImpl.java +++ b/src/com/android/messaging/ui/UIIntentsImpl.java @@ -50,7 +50,8 @@ import com.android.messaging.ui.appsettings.PerSubscriptionSettingsActivity; import com.android.messaging.ui.appsettings.SettingsActivity; import com.android.messaging.ui.attachmentchooser.AttachmentChooserActivity; -import com.android.messaging.ui.conversation.ConversationActivity; +import com.android.messaging.ui.conversation.v2.ConversationActivity; +//import com.android.messaging.ui.conversation.ConversationActivity; import com.android.messaging.ui.conversation.LaunchConversationActivity; import com.android.messaging.ui.conversationlist.ArchivedConversationListActivity; import com.android.messaging.ui.conversationlist.ConversationListActivity; diff --git a/src/com/android/messaging/ui/conversation/v2/ConversationTestTags.kt b/src/com/android/messaging/ui/conversation/v2/ConversationTestTags.kt index 9f5cb31f..4f1a7e2e 100644 --- a/src/com/android/messaging/ui/conversation/v2/ConversationTestTags.kt +++ b/src/com/android/messaging/ui/conversation/v2/ConversationTestTags.kt @@ -18,6 +18,10 @@ internal const val CONVERSATION_DELETE_CONVERSATION_BUTTON_TEST_TAG = internal const val CONVERSATION_LOADING_INDICATOR_TEST_TAG = "conversation_loading_indicator" internal const val CONVERSATION_MESSAGES_LIST_TEST_TAG = "conversation_messages_list" internal const val CONVERSATION_MEDIA_PICKER_OVERLAY_TEST_TAG = "conversation_media_picker_overlay" +internal const val CONVERSATION_INLINE_AUDIO_ATTACHMENT_PLAY_BUTTON_TEST_TAG = + "conversation_inline_audio_attachment_play_button" +internal const val CONVERSATION_INLINE_AUDIO_ATTACHMENT_PROGRESS_TEST_TAG = + "conversation_inline_audio_attachment_progress" internal const val ADD_PARTICIPANTS_CONFIRM_BUTTON_TEST_TAG = "add_participants_confirm_button" internal const val NEW_CHAT_CREATE_GROUP_NEXT_BUTTON_TEST_TAG = diff --git a/src/com/android/messaging/ui/conversation/v2/messages/model/attachment/ConversationInlineAttachment.kt b/src/com/android/messaging/ui/conversation/v2/messages/model/attachment/ConversationInlineAttachment.kt index 19118011..a1e43ec6 100644 --- a/src/com/android/messaging/ui/conversation/v2/messages/model/attachment/ConversationInlineAttachment.kt +++ b/src/com/android/messaging/ui/conversation/v2/messages/model/attachment/ConversationInlineAttachment.kt @@ -5,6 +5,7 @@ import androidx.compose.runtime.Immutable @Immutable internal data class ConversationInlineAttachment( val key: String, + val contentUri: String?, val kind: ConversationInlineAttachmentKind, val openAction: ConversationAttachmentOpenAction?, val subtitleTextResId: Int?, diff --git a/src/com/android/messaging/ui/conversation/v2/messages/ui/attachment/ConversationAttachmentSectionsBuilder.kt b/src/com/android/messaging/ui/conversation/v2/messages/ui/attachment/ConversationAttachmentSectionsBuilder.kt index 812a4b61..a511480d 100644 --- a/src/com/android/messaging/ui/conversation/v2/messages/ui/attachment/ConversationAttachmentSectionsBuilder.kt +++ b/src/com/android/messaging/ui/conversation/v2/messages/ui/attachment/ConversationAttachmentSectionsBuilder.kt @@ -118,6 +118,7 @@ private fun toMediaInlineAttachment( attachment.part.isAudioAttachment -> { createAudioInlineAttachment( key = attachment.key, + contentUri = attachment.part.contentUri.toString(), openAction = attachment.toConversationAttachmentOpenActionOrNull(), ) } @@ -143,10 +144,12 @@ private fun toMediaInlineAttachment( private fun createAudioInlineAttachment( key: String, + contentUri: String, openAction: ConversationAttachmentOpenAction?, ): ConversationInlineAttachment { return ConversationInlineAttachment( key = key, + contentUri = contentUri, kind = ConversationInlineAttachmentKind.AUDIO, openAction = openAction, subtitleTextResId = null, @@ -161,6 +164,7 @@ private fun createVCardInlineAttachment( ): ConversationInlineAttachment { return ConversationInlineAttachment( key = key, + contentUri = null, kind = ConversationInlineAttachmentKind.VCARD, openAction = openAction, subtitleTextResId = R.string.vcard_tap_hint, @@ -176,6 +180,7 @@ private fun createFileInlineAttachment( ): ConversationInlineAttachment { return ConversationInlineAttachment( key = key, + contentUri = null, kind = ConversationInlineAttachmentKind.FILE, openAction = openAction, subtitleTextResId = null, diff --git a/src/com/android/messaging/ui/conversation/v2/messages/ui/attachment/ConversationGenericInlineAttachmentRow.kt b/src/com/android/messaging/ui/conversation/v2/messages/ui/attachment/ConversationGenericInlineAttachmentRow.kt new file mode 100644 index 00000000..63864534 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/v2/messages/ui/attachment/ConversationGenericInlineAttachmentRow.kt @@ -0,0 +1,134 @@ +package com.android.messaging.ui.conversation.v2.messages.ui.attachment + +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 +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Description +import androidx.compose.material.icons.rounded.Person +import androidx.compose.material.icons.rounded.PlayArrow +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.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.android.messaging.R +import com.android.messaging.ui.conversation.v2.messages.model.attachment.ConversationInlineAttachment +import com.android.messaging.ui.conversation.v2.messages.model.attachment.ConversationInlineAttachmentKind + +@Composable +internal fun ConversationGenericInlineAttachmentRow( + attachment: ConversationInlineAttachment, + onAttachmentClick: (contentType: String, contentUri: String) -> Unit, + onExternalUriClick: (String) -> Unit, + onLongClick: () -> Unit, +) { + val title = attachment + .titleText + ?: attachment.titleTextResId?.let { stringResource(it) }.orEmpty() + + val subtitle = attachment.subtitleTextResId?.let { stringResource(it) } + + val onClick = attachment.openAction?.let { action -> + { + dispatchConversationAttachmentOpenAction( + action = action, + onAttachmentClick = onAttachmentClick, + onExternalUriClick = onExternalUriClick, + ) + } + } + + val shape = RoundedCornerShape(size = MESSAGE_ATTACHMENT_CORNER_RADIUS) + + Surface( + modifier = Modifier + .fillMaxWidth() + .clip(shape = shape) + .combinedClickable( + enabled = true, + onClick = { + onClick?.invoke() + }, + onLongClick = onLongClick, + ), + color = MaterialTheme.colorScheme.surfaceContainerHighest, + shape = shape, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 14.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.spacedBy(space = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier.size(size = 28.dp), + contentAlignment = Alignment.Center, + ) { + ConversationInlineAttachmentIcon( + kind = attachment.kind, + ) + } + + Column( + verticalArrangement = Arrangement.spacedBy(space = 2.dp), + ) { + Text( + text = title, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + + subtitle?.let { + Text( + text = subtitle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } +} + +@Composable +private fun ConversationInlineAttachmentIcon( + kind: ConversationInlineAttachmentKind, +) { + when (kind) { + ConversationInlineAttachmentKind.AUDIO -> { + Icon( + imageVector = Icons.Rounded.PlayArrow, + contentDescription = stringResource( + id = R.string.audio_play_content_description, + ), + ) + } + + ConversationInlineAttachmentKind.FILE -> { + Icon( + imageVector = Icons.Rounded.Description, + contentDescription = null, + ) + } + + ConversationInlineAttachmentKind.VCARD -> { + Icon( + imageVector = Icons.Rounded.Person, + contentDescription = null, + ) + } + } +} diff --git a/src/com/android/messaging/ui/conversation/v2/messages/ui/attachment/ConversationInlineAttachmentRow.kt b/src/com/android/messaging/ui/conversation/v2/messages/ui/attachment/ConversationInlineAttachmentRow.kt index 62f1a024..2f38dce1 100644 --- a/src/com/android/messaging/ui/conversation/v2/messages/ui/attachment/ConversationInlineAttachmentRow.kt +++ b/src/com/android/messaging/ui/conversation/v2/messages/ui/attachment/ConversationInlineAttachmentRow.kt @@ -1,132 +1,39 @@ package com.android.messaging.ui.conversation.v2.messages.ui.attachment -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 -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.Description -import androidx.compose.material.icons.rounded.Person -import androidx.compose.material.icons.rounded.PlayArrow -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.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import com.android.messaging.R import com.android.messaging.ui.conversation.v2.messages.model.attachment.ConversationInlineAttachment import com.android.messaging.ui.conversation.v2.messages.model.attachment.ConversationInlineAttachmentKind @Composable internal fun ConversationInlineAttachmentRow( attachment: ConversationInlineAttachment, + isIncoming: Boolean, + isSelectionMode: Boolean, + useStandaloneAudioAttachmentBackground: Boolean, onAttachmentClick: (contentType: String, contentUri: String) -> Unit, onExternalUriClick: (String) -> Unit, onLongClick: () -> Unit = {}, ) { - val title = attachment.titleText - ?: attachment.titleTextResId?.let { stringResource(it) }.orEmpty() - - val subtitle = attachment.subtitleTextResId?.let { stringResource(it) } - - val onClick = attachment.openAction?.let { action -> - { - dispatchConversationAttachmentOpenAction( - action = action, - onAttachmentClick = onAttachmentClick, - onExternalUriClick = onExternalUriClick, - ) - } - } - - val shape = RoundedCornerShape(size = MESSAGE_ATTACHMENT_CORNER_RADIUS) - - Surface( - modifier = Modifier - .fillMaxWidth() - .clip(shape = shape) - .combinedClickable( - enabled = true, - onClick = { - onClick?.invoke() - }, + val shouldUseEmbeddedAudioPlayer = attachment.kind == ConversationInlineAttachmentKind.AUDIO && + !attachment.contentUri.isNullOrBlank() + + when { + shouldUseEmbeddedAudioPlayer -> { + ConversationInlineAudioAttachmentRow( + attachment = attachment, + isIncoming = isIncoming, + isSelectionMode = isSelectionMode, + useStandaloneAudioAttachmentBackground = useStandaloneAudioAttachmentBackground, onLongClick = onLongClick, - ), - color = MaterialTheme.colorScheme.surfaceContainerHighest, - shape = shape, - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 14.dp, vertical = 12.dp), - horizontalArrangement = Arrangement.spacedBy(space = 12.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Box( - modifier = Modifier.size(size = 28.dp), - contentAlignment = Alignment.Center, - ) { - ConversationInlineAttachmentIcon( - kind = attachment.kind, - ) - } - - Column( - verticalArrangement = Arrangement.spacedBy(space = 2.dp), - ) { - Text( - text = title, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface, - ) - - subtitle?.let { - Text( - text = subtitle, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - } - } -} - -@Composable -private fun ConversationInlineAttachmentIcon( - kind: ConversationInlineAttachmentKind, -) { - when (kind) { - ConversationInlineAttachmentKind.AUDIO -> { - Icon( - imageVector = Icons.Rounded.PlayArrow, - contentDescription = stringResource( - id = R.string.audio_play_content_description, - ), ) } - ConversationInlineAttachmentKind.FILE -> { - Icon( - imageVector = Icons.Rounded.Description, - contentDescription = null, - ) - } - - ConversationInlineAttachmentKind.VCARD -> { - Icon( - imageVector = Icons.Rounded.Person, - contentDescription = null, + else -> { + ConversationGenericInlineAttachmentRow( + attachment = attachment, + onAttachmentClick = onAttachmentClick, + onExternalUriClick = onExternalUriClick, + onLongClick = onLongClick, ) } } diff --git a/src/com/android/messaging/ui/conversation/v2/messages/ui/attachment/ConversationInlineAudioAttachmentPlaybackState.kt b/src/com/android/messaging/ui/conversation/v2/messages/ui/attachment/ConversationInlineAudioAttachmentPlaybackState.kt new file mode 100644 index 00000000..fda42b37 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/v2/messages/ui/attachment/ConversationInlineAudioAttachmentPlaybackState.kt @@ -0,0 +1,215 @@ +package com.android.messaging.ui.conversation.v2.messages.ui.attachment + +import android.content.Context +import android.media.MediaPlayer +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.core.net.toUri +import com.android.messaging.R +import com.android.messaging.util.UiUtils +import java.util.Locale +import kotlin.time.Duration.Companion.milliseconds +import kotlinx.coroutines.delay + +private val audioProgressUpdateIntervalMs = 250L.milliseconds + +@Composable +internal fun rememberConversationInlineAudioAttachmentPlaybackState( + contentUri: String, +): ConversationInlineAudioAttachmentPlaybackState { + val playbackState = remember(contentUri) { + ConversationInlineAudioAttachmentPlaybackState( + onPlaybackFailure = { + UiUtils.showToastAtBottom(R.string.audio_recording_replay_failed) + }, + ) + } + + DisposableEffect(contentUri) { + onDispose { + playbackState.release() + } + } + + LaunchedEffect(playbackState.isPlaying, contentUri) { + while (playbackState.isPlaying) { + playbackState.updateProgress() + delay(audioProgressUpdateIntervalMs) + } + } + + return playbackState +} + +@Stable +internal class ConversationInlineAudioAttachmentPlaybackState( + private val onPlaybackFailure: () -> Unit, +) { + var durationMillis by mutableLongStateOf(0L) + private set + + var isPlaying by mutableStateOf(false) + private set + + var positionMillis by mutableLongStateOf(0L) + private set + + private var hasPlaybackCompleted by mutableStateOf(false) + private var isPrepared by mutableStateOf(false) + private var mediaPlayer by mutableStateOf(null) + private var shouldStartPlaybackWhenPrepared by mutableStateOf(false) + + val durationLabel: String + get() { + return formatAudioDuration( + durationMillis = when { + durationMillis > 0L -> durationMillis + else -> positionMillis + }, + positionMillis = positionMillis, + ) + } + + val progress: Float + get() { + return calculateAudioProgress( + durationMillis = durationMillis, + positionMillis = positionMillis, + ) + } + + fun release() { + mediaPlayer?.release() + mediaPlayer = null + isPrepared = false + isPlaying = false + shouldStartPlaybackWhenPrepared = false + } + + fun togglePlayback( + context: Context, + contentUri: String, + ) { + val currentMediaPlayer = mediaPlayer + + if (currentMediaPlayer == null) { + shouldStartPlaybackWhenPrepared = true + ensureMediaPlayer( + context = context, + contentUri = contentUri, + ) + return + } + + if (!isPrepared) { + shouldStartPlaybackWhenPrepared = !shouldStartPlaybackWhenPrepared + return + } + + if (isPlaying) { + currentMediaPlayer.pause() + positionMillis = currentMediaPlayer.currentPosition.toLong().coerceAtLeast(0L) + isPlaying = false + return + } + + startPlayback() + } + + fun updateProgress() { + val currentMediaPlayer = mediaPlayer ?: return + positionMillis = currentMediaPlayer.currentPosition.toLong().coerceAtLeast(0L) + } + + private fun ensureMediaPlayer( + context: Context, + contentUri: String, + ) { + if (mediaPlayer != null) { + return + } + + val createdMediaPlayer = MediaPlayer() + mediaPlayer = createdMediaPlayer + + try { + createdMediaPlayer.setDataSource(context, contentUri.toUri()) + createdMediaPlayer.setOnCompletionListener { + isPlaying = false + hasPlaybackCompleted = true + positionMillis = 0L + } + createdMediaPlayer.setOnErrorListener { _, _, _ -> + handlePlaybackFailure() + true + } + createdMediaPlayer.setOnPreparedListener { preparedMediaPlayer -> + isPrepared = true + durationMillis = preparedMediaPlayer.duration.toLong().coerceAtLeast(0L) + positionMillis = 0L + if (shouldStartPlaybackWhenPrepared) { + shouldStartPlaybackWhenPrepared = false + startPlayback() + } + } + createdMediaPlayer.prepareAsync() + } catch (_: Exception) { + handlePlaybackFailure() + } + } + + private fun handlePlaybackFailure() { + onPlaybackFailure() + release() + durationMillis = 0L + positionMillis = 0L + hasPlaybackCompleted = false + } + + private fun startPlayback() { + val currentMediaPlayer = mediaPlayer ?: return + + if (hasPlaybackCompleted) { + currentMediaPlayer.seekTo(0) + positionMillis = 0L + hasPlaybackCompleted = false + } + + currentMediaPlayer.start() + isPlaying = true + } +} + +private fun calculateAudioProgress( + durationMillis: Long, + positionMillis: Long, +): Float { + return when { + durationMillis <= 0L -> 0f + else -> { + (positionMillis.toFloat() / durationMillis.toFloat()).coerceIn(0f, 1f) + } + } +} + +private fun formatAudioDuration( + durationMillis: Long, + positionMillis: Long, +): String { + val displayedMillis = when { + positionMillis > 0L -> positionMillis + else -> durationMillis + } + val totalSeconds = displayedMillis / 1_000L + val minutes = totalSeconds / 60L + val seconds = totalSeconds % 60L + + return String.format(Locale.getDefault(), "%02d:%02d", minutes, seconds) +} diff --git a/src/com/android/messaging/ui/conversation/v2/messages/ui/attachment/ConversationInlineAudioAttachmentRow.kt b/src/com/android/messaging/ui/conversation/v2/messages/ui/attachment/ConversationInlineAudioAttachmentRow.kt new file mode 100644 index 00000000..70e379fa --- /dev/null +++ b/src/com/android/messaging/ui/conversation/v2/messages/ui/attachment/ConversationInlineAudioAttachmentRow.kt @@ -0,0 +1,343 @@ +package com.android.messaging.ui.conversation.v2.messages.ui.attachment + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +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.RowScope +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Pause +import androidx.compose.material.icons.rounded.PlayArrow +import androidx.compose.material3.Icon +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.android.messaging.R +import com.android.messaging.ui.conversation.v2.CONVERSATION_INLINE_AUDIO_ATTACHMENT_PLAY_BUTTON_TEST_TAG +import com.android.messaging.ui.conversation.v2.CONVERSATION_INLINE_AUDIO_ATTACHMENT_PROGRESS_TEST_TAG +import com.android.messaging.ui.conversation.v2.messages.model.attachment.ConversationInlineAttachment + +private val AUDIO_ATTACHMENT_HEIGHT = 70.dp + +@Composable +internal fun ConversationInlineAudioAttachmentRow( + attachment: ConversationInlineAttachment, + isIncoming: Boolean, + isSelectionMode: Boolean, + useStandaloneAudioAttachmentBackground: Boolean, + onLongClick: () -> Unit, +) { + val context = LocalContext.current + val contentUri = attachment.contentUri ?: return + + val title = attachment.titleText + ?: attachment.titleTextResId?.let { stringResource(it) } + ?: stringResource(R.string.audio_attachment_content_description) + + val colors = rememberConversationInlineAudioAttachmentColors( + isIncoming = isIncoming, + isSelectionMode = isSelectionMode, + useStandaloneAudioAttachmentBackground = useStandaloneAudioAttachmentBackground, + ) + + val playbackState = rememberConversationInlineAudioAttachmentPlaybackState( + contentUri = contentUri, + ) + + ConversationInlineAudioAttachmentRowContent( + colors = colors, + isSelectionMode = isSelectionMode, + isPlaying = playbackState.isPlaying, + title = title, + durationLabel = playbackState.durationLabel, + progress = playbackState.progress, + onClick = { + playbackState.togglePlayback( + context = context, + contentUri = contentUri, + ) + }, + onLongClick = onLongClick, + ) +} + +@Composable +internal fun ConversationInlineAudioAttachmentRowContent( + colors: ConversationInlineAudioAttachmentColors, + isSelectionMode: Boolean, + isPlaying: Boolean, + title: String, + durationLabel: String, + progress: Float, + onClick: () -> Unit, + onLongClick: () -> Unit, +) { + val modifier = when { + isSelectionMode -> Modifier + + else -> { + Modifier.combinedClickable( + onClick = onClick, + onLongClick = onLongClick, + ) + } + } + + Surface( + modifier = Modifier + .fillMaxWidth() + .height(height = AUDIO_ATTACHMENT_HEIGHT) + .then(modifier), + color = colors.container, + shape = RoundedCornerShape(size = MESSAGE_ATTACHMENT_CORNER_RADIUS), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 14.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.spacedBy(space = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + ConversationInlineAudioAttachmentPlayButton( + isPlaying = isPlaying, + colors = colors, + ) + + ConversationInlineAudioAttachmentContent( + title = title, + durationLabel = durationLabel, + isPlaying = isPlaying, + progress = progress, + colors = colors, + ) + } + } +} + +@Composable +private fun ConversationInlineAudioAttachmentPlayButton( + isPlaying: Boolean, + colors: ConversationInlineAudioAttachmentColors, +) { + Surface( + modifier = Modifier + .size(size = 40.dp) + .testTag(tag = CONVERSATION_INLINE_AUDIO_ATTACHMENT_PLAY_BUTTON_TEST_TAG), + color = colors.playButton, + shape = RoundedCornerShape(size = 20.dp), + ) { + Box( + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = when { + isPlaying -> Icons.Rounded.Pause + else -> Icons.Rounded.PlayArrow + }, + contentDescription = when { + isPlaying -> stringResource(R.string.audio_pause_content_description) + else -> stringResource(R.string.audio_play_content_description) + }, + tint = colors.playIcon, + ) + } + } +} + +@Composable +private fun RowScope.ConversationInlineAudioAttachmentContent( + title: String, + durationLabel: String, + isPlaying: Boolean, + progress: Float, + colors: ConversationInlineAudioAttachmentColors, +) { + val shouldShowProgress = isPlaying || progress > 0f + + Column( + modifier = Modifier + .weight(weight = 1f) + .animateContentSize(), + verticalArrangement = Arrangement.spacedBy(space = 6.dp), + ) { + Row( + modifier = Modifier + .fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + modifier = Modifier + .weight(weight = 1f, fill = false), + text = title, + style = MaterialTheme.typography.bodyMedium, + color = colors.content, + maxLines = 1, + ) + + Text( + modifier = Modifier + .width(width = 48.dp), + text = durationLabel, + style = MaterialTheme.typography.labelMedium, + color = colors.secondaryContent, + ) + } + + AnimatedVisibility( + visible = shouldShowProgress, + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically(), + ) { + LinearProgressIndicator( + modifier = Modifier + .fillMaxWidth() + .testTag(tag = CONVERSATION_INLINE_AUDIO_ATTACHMENT_PROGRESS_TEST_TAG), + progress = { progress }, + color = colors.progress, + drawStopIndicator = {}, + strokeCap = StrokeCap.Butt, + trackColor = colors.progressTrack, + ) + } + } +} + +@Composable +internal fun rememberConversationInlineAudioAttachmentColors( + isIncoming: Boolean, + isSelectionMode: Boolean, + useStandaloneAudioAttachmentBackground: Boolean, +): ConversationInlineAudioAttachmentColors { + return ConversationInlineAudioAttachmentColors( + container = getAudioAttachmentContainerColor( + isIncoming = isIncoming, + useStandaloneAudioAttachmentBackground = useStandaloneAudioAttachmentBackground, + ), + content = getAudioAttachmentContentColor(isIncoming = isIncoming), + playButton = getAudioAttachmentPlayButtonColor( + isIncoming = isIncoming, + isSelectionMode = isSelectionMode, + ), + playIcon = getAudioAttachmentPlayIconColor( + isIncoming = isIncoming, + isSelectionMode = isSelectionMode, + ), + progress = getAudioAttachmentProgressColor(isIncoming = isIncoming), + progressTrack = getAudioAttachmentProgressTrackColor(isIncoming = isIncoming), + secondaryContent = getAudioAttachmentSecondaryContentColor(isIncoming = isIncoming), + ) +} + + +@Composable +private fun getAudioAttachmentContainerColor( + isIncoming: Boolean, + useStandaloneAudioAttachmentBackground: Boolean, +): Color { + return when { + !useStandaloneAudioAttachmentBackground -> { + MaterialTheme.colorScheme.surfaceContainerHighest + } + + isIncoming -> MaterialTheme.colorScheme.surfaceContainerHighest + else -> MaterialTheme.colorScheme.primaryContainer + } +} + +@Composable +private fun getAudioAttachmentContentColor( + isIncoming: Boolean, +): Color { + return when { + isIncoming -> MaterialTheme.colorScheme.onSurface + else -> MaterialTheme.colorScheme.onPrimaryContainer + } +} + +@Composable +private fun getAudioAttachmentPlayButtonColor( + isIncoming: Boolean, + isSelectionMode: Boolean, +): Color { + return when { + isSelectionMode -> MaterialTheme.colorScheme.surfaceVariant + isIncoming -> MaterialTheme.colorScheme.secondaryContainer + else -> MaterialTheme.colorScheme.primary + } +} + +@Composable +private fun getAudioAttachmentPlayIconColor( + isIncoming: Boolean, + isSelectionMode: Boolean, +): Color { + return when { + isSelectionMode -> MaterialTheme.colorScheme.onSurfaceVariant + isIncoming -> MaterialTheme.colorScheme.onSecondaryContainer + else -> MaterialTheme.colorScheme.onPrimary + } +} + +@Composable +private fun getAudioAttachmentProgressColor( + isIncoming: Boolean, +): Color { + return when { + isIncoming -> MaterialTheme.colorScheme.primary + else -> MaterialTheme.colorScheme.onPrimaryContainer + } +} + +@Composable +private fun getAudioAttachmentProgressTrackColor( + isIncoming: Boolean, +): Color { + return when { + isIncoming -> MaterialTheme.colorScheme.surfaceVariant + else -> MaterialTheme.colorScheme.primary.copy(alpha = 0.2f) + } +} + +@Composable +private fun getAudioAttachmentSecondaryContentColor( + isIncoming: Boolean, +): Color { + return when { + isIncoming -> MaterialTheme.colorScheme.onSurfaceVariant + else -> MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.8f) + } +} + +internal data class ConversationInlineAudioAttachmentColors( + val container: Color, + val content: Color, + val playButton: Color, + val playIcon: Color, + val progress: Color, + val progressTrack: Color, + val secondaryContent: Color, +) diff --git a/src/com/android/messaging/ui/conversation/v2/messages/ui/attachment/ConversationMessageAttachments.kt b/src/com/android/messaging/ui/conversation/v2/messages/ui/attachment/ConversationMessageAttachments.kt index 0c26af93..adbabef3 100644 --- a/src/com/android/messaging/ui/conversation/v2/messages/ui/attachment/ConversationMessageAttachments.kt +++ b/src/com/android/messaging/ui/conversation/v2/messages/ui/attachment/ConversationMessageAttachments.kt @@ -14,6 +14,9 @@ internal fun ConversationMessageAttachments( attachmentSections: ConversationAttachmentSections, hasTextAboveVisualAttachments: Boolean, hasTextBelowVisualAttachments: Boolean, + isIncoming: Boolean, + isSelectionMode: Boolean, + useStandaloneAudioAttachmentBg: Boolean, onAttachmentClick: (contentType: String, contentUri: String) -> Unit, onExternalUriClick: (String) -> Unit, onMessageLongClick: () -> Unit, @@ -45,6 +48,9 @@ internal fun ConversationMessageAttachments( is ConversationAttachmentItem.Inline -> { ConversationInlineAttachmentRow( attachment = trailingItem.attachment, + isIncoming = isIncoming, + isSelectionMode = isSelectionMode, + useStandaloneAudioAttachmentBackground = useStandaloneAudioAttachmentBg, onAttachmentClick = onAttachmentClick, onExternalUriClick = onExternalUriClick, onLongClick = onMessageLongClick, diff --git a/src/com/android/messaging/ui/conversation/v2/messages/ui/attachment/ConversationVisualAttachments.kt b/src/com/android/messaging/ui/conversation/v2/messages/ui/attachment/ConversationVisualAttachments.kt index 54938a4d..d4dd0d02 100644 --- a/src/com/android/messaging/ui/conversation/v2/messages/ui/attachment/ConversationVisualAttachments.kt +++ b/src/com/android/messaging/ui/conversation/v2/messages/ui/attachment/ConversationVisualAttachments.kt @@ -36,7 +36,7 @@ import com.android.messaging.ui.conversation.v2.messages.model.message.Conversat import com.android.messaging.util.ContentType import kotlinx.collections.immutable.ImmutableList -internal val MESSAGE_ATTACHMENT_CORNER_RADIUS = 18.dp +internal val MESSAGE_ATTACHMENT_CORNER_RADIUS = 0.dp internal val MESSAGE_ATTACHMENT_GRID_SPACING = 6.dp private const val MESSAGE_ATTACHMENT_DEFAULT_IMAGE_ASPECT_RATIO = 4f / 3f private const val MESSAGE_ATTACHMENT_DEFAULT_VIDEO_ASPECT_RATIO = 16f / 9f diff --git a/src/com/android/messaging/ui/conversation/v2/messages/ui/message/ConversationMessage.kt b/src/com/android/messaging/ui/conversation/v2/messages/ui/message/ConversationMessage.kt index b810b513..eeb767dd 100644 --- a/src/com/android/messaging/ui/conversation/v2/messages/ui/message/ConversationMessage.kt +++ b/src/com/android/messaging/ui/conversation/v2/messages/ui/message/ConversationMessage.kt @@ -255,6 +255,7 @@ private fun ConversationMessageContent( modifier = bubbleInteractionModifier, message = message, isSelected = isSelected, + isSelectionMode = isSelectionMode, layout = layout, maxBubbleWidth = maxBubbleWidth, onAttachmentClick = { contentType, contentUri -> @@ -294,6 +295,7 @@ private fun ConversationMessageBubble( modifier: Modifier = Modifier, message: ConversationMessageUiModel, isSelected: Boolean, + isSelectionMode: Boolean, layout: ConversationMessageLayout, maxBubbleWidth: Dp, onAttachmentClick: (contentType: String, contentUri: String) -> Unit, @@ -316,6 +318,7 @@ private fun ConversationMessageBubble( content = layout.content, message = message, isSelected = isSelected, + isSelectionMode = isSelectionMode, senderDisplayName = message.senderDisplayName, showSender = layout.showSender, onAttachmentClick = onAttachmentClick, @@ -338,6 +341,7 @@ private fun ConversationMessageBubble( content = layout.content, message = message, isSelected = isSelected, + isSelectionMode = isSelectionMode, senderDisplayName = message.senderDisplayName, showSender = layout.showSender, onAttachmentClick = onAttachmentClick, @@ -360,6 +364,7 @@ private fun ConversationMessageBubble( content = layout.content, message = message, isSelected = isSelected, + isSelectionMode = isSelectionMode, senderDisplayName = message.senderDisplayName, showSender = layout.showSender, onAttachmentClick = onAttachmentClick, @@ -438,6 +443,7 @@ private fun ConversationMessageTextBubbleContent( content: ConversationMessageContent, message: ConversationMessageUiModel, isSelected: Boolean, + isSelectionMode: Boolean, senderDisplayName: String?, showSender: Boolean, onAttachmentClick: (contentType: String, contentUri: String) -> Unit, @@ -462,6 +468,8 @@ private fun ConversationMessageTextBubbleContent( ConversationMessageBody( content = content, + isIncoming = message.isIncoming, + isSelectionMode = isSelectionMode, onAttachmentClick = onAttachmentClick, onExternalUriClick = onExternalUriClick, onMessageLongClick = onMessageLongClick, @@ -475,6 +483,7 @@ private fun ConversationMessageAttachmentBubbleContent( content: ConversationMessageContent, message: ConversationMessageUiModel, isSelected: Boolean, + isSelectionMode: Boolean, senderDisplayName: String?, showSender: Boolean, onAttachmentClick: (contentType: String, contentUri: String) -> Unit, @@ -521,6 +530,9 @@ private fun ConversationMessageAttachmentBubbleContent( attachmentSections = content.attachmentSections, hasTextAboveVisualAttachments = hasHeader, hasTextBelowVisualAttachments = hasBodyText, + isIncoming = message.isIncoming, + isSelectionMode = isSelectionMode, + useStandaloneAudioAttachmentBg = false, onAttachmentClick = onAttachmentClick, onExternalUriClick = onExternalUriClick, onMessageLongClick = onMessageLongClick, @@ -545,6 +557,8 @@ private fun ConversationMessageAttachmentBubbleContent( @Composable private fun ConversationMessageBody( content: ConversationMessageContent, + isIncoming: Boolean, + isSelectionMode: Boolean, onAttachmentClick: (contentType: String, contentUri: String) -> Unit, onExternalUriClick: (String) -> Unit, onMessageLongClick: () -> Unit, @@ -560,6 +574,9 @@ private fun ConversationMessageBody( attachmentSections = content.attachmentSections, hasTextAboveVisualAttachments = false, hasTextBelowVisualAttachments = false, + isIncoming = isIncoming, + isSelectionMode = isSelectionMode, + useStandaloneAudioAttachmentBg = true, onAttachmentClick = onAttachmentClick, onExternalUriClick = onExternalUriClick, onMessageLongClick = onMessageLongClick, diff --git a/src/com/android/messaging/ui/conversation/v2/messages/ui/message/ConversationMessageContentBuilder.kt b/src/com/android/messaging/ui/conversation/v2/messages/ui/message/ConversationMessageContentBuilder.kt index c75fa641..4f0ac245 100644 --- a/src/com/android/messaging/ui/conversation/v2/messages/ui/message/ConversationMessageContentBuilder.kt +++ b/src/com/android/messaging/ui/conversation/v2/messages/ui/message/ConversationMessageContentBuilder.kt @@ -129,10 +129,6 @@ private fun buildConversationMessageBodyText( return when { captionText != null -> captionText - attachments.isNotEmpty() -> { - message.parts.firstOrNull()?.contentType?.takeIf { it.isNotBlank() } - } - else -> null } } From 1dad16b6443ca5d65594a12deb3db8c6241cd2ca Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Tue, 21 Apr 2026 17:36:21 +0300 Subject: [PATCH 51/99] Add vCard support for inline attachments --- res/values/strings.xml | 2 + .../android/messaging/debug/TestDataSeeder.kt | 72 ++++-- .../conversation/ConversationBindsModule.kt | 16 ++ .../ConversationMessageSelectionDelegate.kt | 12 +- .../delegate/ConversationMessagesDelegate.kt | 129 +++++++++- .../ConversationMessageUiModelMapper.kt | 67 ++++- .../ConversationInlineAttachment.kt | 45 ++-- .../ConversationMessageAttachment.kt | 4 +- .../ConversationVCardAttachmentMetadata.kt | 24 ++ .../ConversationVCardAttachmentUiState.kt | 16 ++ .../message/ConversationMessagePartUiModel.kt | 95 +++++--- .../ConversationVCardMetadataMapper.kt | 47 ++++ .../ConversationVCardMetadataRepository.kt | 71 ++++++ .../ConversationAttachmentSectionsBuilder.kt | 43 ++-- .../ConversationGenericInlineAttachmentRow.kt | 42 +--- .../ConversationInlineAttachmentRow.kt | 20 +- .../ConversationInlineAudioAttachmentRow.kt | 4 +- .../ConversationVCardInlineAttachmentRow.kt | 228 ++++++++++++++++++ .../ConversationVisualAttachments.kt | 16 +- .../ConversationMessageContentBuilder.kt | 31 ++- 20 files changed, 816 insertions(+), 168 deletions(-) create mode 100644 src/com/android/messaging/ui/conversation/v2/messages/model/attachment/ConversationVCardAttachmentMetadata.kt create mode 100644 src/com/android/messaging/ui/conversation/v2/messages/model/attachment/ConversationVCardAttachmentUiState.kt create mode 100644 src/com/android/messaging/ui/conversation/v2/messages/repository/ConversationVCardMetadataMapper.kt create mode 100644 src/com/android/messaging/ui/conversation/v2/messages/repository/ConversationVCardMetadataRepository.kt create mode 100644 src/com/android/messaging/ui/conversation/v2/messages/ui/attachment/ConversationVCardInlineAttachmentRow.kt diff --git a/res/values/strings.xml b/res/values/strings.xml index 571531ce..13599802 100644 --- a/res/values/strings.xml +++ b/res/values/strings.xml @@ -348,6 +348,8 @@ Video Contact card + + Location File diff --git a/src/com/android/messaging/debug/TestDataSeeder.kt b/src/com/android/messaging/debug/TestDataSeeder.kt index a49de6bd..ce228fa0 100644 --- a/src/com/android/messaging/debug/TestDataSeeder.kt +++ b/src/com/android/messaging/debug/TestDataSeeder.kt @@ -42,9 +42,10 @@ private const val MEDIA_SCRATCH_FILE_EXTENSION_QUERY_PARAMETER = "ext" private const val SEED_IMAGE_1_FILE_ID = "800001" private const val SEED_IMAGE_2_FILE_ID = "800002" private const val SEED_IMAGE_3_FILE_ID = "800003" -private const val SEED_VCARD_FILE_ID = "800004" +private const val SEED_CONTACT_VCARD_FILE_ID = "800004" private const val SEED_VIDEO_FILE_ID = "800005" private const val SEED_AUDIO_FILE_ID = "800006" +private const val SEED_LOCATION_VCARD_FILE_ID = "800007" private const val SEED_AUDIO_DURATION_SECONDS = 2 private const val SEED_AUDIO_SAMPLE_RATE_HZ = 16_000 private const val SEED_AUDIO_FREQUENCY_HZ = 440.0 @@ -53,6 +54,11 @@ private const val MINUTES = 60 * 1000L private const val HOURS = 60 * MINUTES private const val DAYS = 24 * HOURS +private data class SeedVCards( + val contactUri: String, + val locationUri: String, +) + fun seedTestData(context: Context) { clearSeededTestData(context = context) @@ -66,7 +72,7 @@ fun seedTestData(context: Context) { val testImages = buildTestImages(context) val testAudio = buildTestAudio() val testVideo = buildTestVideo(context) - val testVCard = buildTestVCard(context) + val testVCards = buildTestVCards() val now = System.currentTimeMillis() db.withTransaction { @@ -88,7 +94,17 @@ fun seedTestData(context: Context) { seedScenarioE(db, selfId, grace, now) seedScenarioF(db, selfId, henry, now) seedScenarioG(db, selfId, iris, testImages, now) - seedScenarioH(db, selfId, jack, carol, testImages, testAudio, testVideo, testVCard, now) + seedScenarioH( + db = db, + selfId = selfId, + jackId = jack, + carolId = carol, + images = testImages, + audioUri = testAudio, + videoUri = testVideo, + vCards = testVCards, + now = now, + ) seedScenarioI(db, selfId, carol, dave, eve, now) } @@ -179,12 +195,14 @@ fun clearSeededTestData(context: Context) { deleteSeedScratchFile(fileId = SEED_IMAGE_1_FILE_ID, fileExtension = "jpg") deleteSeedScratchFile(fileId = SEED_IMAGE_2_FILE_ID, fileExtension = "jpg") deleteSeedScratchFile(fileId = SEED_IMAGE_3_FILE_ID, fileExtension = "jpg") - deleteSeedScratchFile(fileId = SEED_VCARD_FILE_ID, fileExtension = "vcf") + deleteSeedScratchFile(fileId = SEED_CONTACT_VCARD_FILE_ID, fileExtension = "vcf") + deleteSeedScratchFile(fileId = SEED_LOCATION_VCARD_FILE_ID, fileExtension = "vcf") deleteSeedScratchFile(fileId = SEED_VIDEO_FILE_ID, fileExtension = "mp4") deleteSeedScratchFile(fileId = SEED_IMAGE_1_FILE_ID) deleteSeedScratchFile(fileId = SEED_IMAGE_2_FILE_ID) deleteSeedScratchFile(fileId = SEED_IMAGE_3_FILE_ID) - deleteSeedScratchFile(fileId = SEED_VCARD_FILE_ID) + deleteSeedScratchFile(fileId = SEED_CONTACT_VCARD_FILE_ID) + deleteSeedScratchFile(fileId = SEED_LOCATION_VCARD_FILE_ID) deleteSeedScratchFile(fileId = SEED_VIDEO_FILE_ID) deleteSeedScratchFile(fileId = SEED_AUDIO_FILE_ID) deleteSeededAttachmentScratchFiles(attachmentUris = seededAttachmentUris) @@ -240,14 +258,14 @@ private fun buildTestImages(context: Context): List { } } -private fun buildTestVCard(context: Context): String { - val vCardUri = buildSeedScratchUri( - fileId = SEED_VCARD_FILE_ID, +private fun buildTestVCards(): SeedVCards { + val contactVCardUri = buildSeedScratchUri( + fileId = SEED_CONTACT_VCARD_FILE_ID, fileExtension = "vcf", ) - val file = MediaScratchFileProvider.getFileFromUri(vCardUri) - file.parentFile?.mkdirs() - file.writeText( + val contactFile = MediaScratchFileProvider.getFileFromUri(contactVCardUri) + contactFile.parentFile?.mkdirs() + contactFile.writeText( """ BEGIN:VCARD VERSION:3.0 @@ -258,9 +276,31 @@ private fun buildTestVCard(context: Context): String { END:VCARD """.trimIndent(), ) + MediaScratchFileProvider.addUriToDisplayNameEntry(contactVCardUri, "Sam Rivera") - MediaScratchFileProvider.addUriToDisplayNameEntry(vCardUri, "Sam Rivera") - return vCardUri.toString() + val locationVCardUri = buildSeedScratchUri( + fileId = SEED_LOCATION_VCARD_FILE_ID, + fileExtension = "vcf", + ) + val locationFile = MediaScratchFileProvider.getFileFromUri(locationVCardUri) + locationFile.parentFile?.mkdirs() + locationFile.writeText( + """ + BEGIN:VCARD + VERSION:3.0 + KIND:location + FN:Pier 57 + ADR;TYPE=WORK:;;25 11th Ave;New York;NY;10011;United States + NOTE:Meet by the market entrance + END:VCARD + """.trimIndent(), + ) + MediaScratchFileProvider.addUriToDisplayNameEntry(locationVCardUri, "Pier 57") + + return SeedVCards( + contactUri = contactVCardUri.toString(), + locationUri = locationVCardUri.toString(), + ) } private fun buildTestAudio(): String { @@ -1157,7 +1197,7 @@ private fun seedScenarioH( images: List, audioUri: String, videoUri: String, - vCardUri: String, + vCards: SeedVCards, now: Long, ) { val img1 = images[0] @@ -1205,8 +1245,10 @@ private fun seedScenarioH( Msg("text", text = "And here's the ambient audio from the room", senderId = jackId), Msg("audio", attachmentUri = audioUri, senderId = jackId), Msg("text", text = "Send me the photographer contact too", senderId = selfId), - Msg("vcard", attachmentUri = vCardUri, senderId = carolId), + Msg("vcard", attachmentUri = vCards.contactUri, senderId = carolId), Msg("text", text = "One more", senderId = carolId), + Msg("text", text = "Pin the meetup spot too", senderId = selfId), + Msg("vcard", attachmentUri = vCards.locationUri, senderId = jackId), Msg("text", text = "We need to do this again soon", senderId = selfId), Msg("text", text = "+1", senderId = jackId), Msg("text", text = "Same time next week?", senderId = carolId), diff --git a/src/com/android/messaging/di/conversation/ConversationBindsModule.kt b/src/com/android/messaging/di/conversation/ConversationBindsModule.kt index 738ca36f..4d91df6a 100644 --- a/src/com/android/messaging/di/conversation/ConversationBindsModule.kt +++ b/src/com/android/messaging/di/conversation/ConversationBindsModule.kt @@ -42,6 +42,10 @@ import com.android.messaging.ui.conversation.v2.mediapicker.ConversationAttachme import com.android.messaging.ui.conversation.v2.mediapicker.ConversationAttachmentBridgeImpl import com.android.messaging.ui.conversation.v2.messages.mapper.ConversationMessageUiModelMapper import com.android.messaging.ui.conversation.v2.messages.mapper.ConversationMessageUiModelMapperImpl +import com.android.messaging.ui.conversation.v2.messages.repository.ConversationVCardMetadataMapper +import com.android.messaging.ui.conversation.v2.messages.repository.ConversationVCardMetadataMapperImpl +import com.android.messaging.ui.conversation.v2.messages.repository.ConversationVCardMetadataRepository +import com.android.messaging.ui.conversation.v2.messages.repository.ConversationVCardMetadataRepositoryImpl import com.android.messaging.ui.conversation.v2.metadata.mapper.ConversationMetadataUiStateMapper import com.android.messaging.ui.conversation.v2.metadata.mapper.ConversationMetadataUiStateMapperImpl import dagger.Binds @@ -165,6 +169,18 @@ internal abstract class ConversationBindsModule { impl: ConversationMessageUiModelMapperImpl, ): ConversationMessageUiModelMapper + @Binds + @Reusable + abstract fun bindConversationVCardMetadataRepository( + impl: ConversationVCardMetadataRepositoryImpl, + ): ConversationVCardMetadataRepository + + @Binds + @Reusable + abstract fun bindConversationVCardMetadataMapper( + impl: ConversationVCardMetadataMapperImpl, + ): ConversationVCardMetadataMapper + @Binds @Reusable abstract fun bindConversationMediaRepository( diff --git a/src/com/android/messaging/ui/conversation/v2/messages/delegate/ConversationMessageSelectionDelegate.kt b/src/com/android/messaging/ui/conversation/v2/messages/delegate/ConversationMessageSelectionDelegate.kt index 6790310e..359a4489 100644 --- a/src/com/android/messaging/ui/conversation/v2/messages/delegate/ConversationMessageSelectionDelegate.kt +++ b/src/com/android/messaging/ui/conversation/v2/messages/delegate/ConversationMessageSelectionDelegate.kt @@ -6,6 +6,7 @@ import com.android.messaging.data.conversation.repository.ConversationsRepositor import com.android.messaging.di.core.DefaultDispatcher import com.android.messaging.domain.conversation.usecase.CreateForwardedMessage import com.android.messaging.ui.conversation.v2.common.ConversationScreenDelegate +import com.android.messaging.ui.conversation.v2.messages.model.message.ConversationMessagePartUiModel import com.android.messaging.ui.conversation.v2.messages.model.message.ConversationMessageUiModel import com.android.messaging.ui.conversation.v2.messages.model.message.ConversationMessagesUiState import com.android.messaging.ui.conversation.v2.screen.model.ConversationMessageDeleteConfirmationUiState @@ -296,9 +297,14 @@ internal class ConversationMessageSelectionDelegateImpl @Inject constructor( val firstAttachment = when { messageText != null -> null else -> { - selectedMessage.parts.firstOrNull { part -> - !part.contentType.isBlank() && part.contentUri != null - } + selectedMessage.parts + .asSequence() + .mapNotNull { part -> + part as? ConversationMessagePartUiModel.Attachment + } + .firstOrNull { attachment -> + attachment.contentType.isNotBlank() && attachment.contentUri != null + } } } diff --git a/src/com/android/messaging/ui/conversation/v2/messages/delegate/ConversationMessagesDelegate.kt b/src/com/android/messaging/ui/conversation/v2/messages/delegate/ConversationMessagesDelegate.kt index 3785c12e..95a24286 100644 --- a/src/com/android/messaging/ui/conversation/v2/messages/delegate/ConversationMessagesDelegate.kt +++ b/src/com/android/messaging/ui/conversation/v2/messages/delegate/ConversationMessagesDelegate.kt @@ -4,15 +4,24 @@ import com.android.messaging.data.conversation.repository.ConversationsRepositor import com.android.messaging.di.core.DefaultDispatcher import com.android.messaging.ui.conversation.v2.common.ConversationScreenDelegate import com.android.messaging.ui.conversation.v2.messages.mapper.ConversationMessageUiModelMapper +import com.android.messaging.ui.conversation.v2.messages.model.attachment.ConversationVCardAttachmentMetadata +import com.android.messaging.ui.conversation.v2.messages.model.message.ConversationMessagePartUiModel +import com.android.messaging.ui.conversation.v2.messages.model.message.ConversationMessageUiModel import com.android.messaging.ui.conversation.v2.messages.model.message.ConversationMessagesUiState +import com.android.messaging.ui.conversation.v2.messages.repository.ConversationVCardMetadataRepository import javax.inject.Inject import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch @@ -23,6 +32,7 @@ internal interface ConversationMessagesDelegate : internal class ConversationMessagesDelegateImpl @Inject constructor( private val conversationsRepository: ConversationsRepository, private val conversationMessageUiModelMapper: ConversationMessageUiModelMapper, + private val conversationVCardMetadataRepository: ConversationVCardMetadataRepository, @param:DefaultDispatcher private val defaultDispatcher: CoroutineDispatcher, ) : ConversationMessagesDelegate { @@ -53,21 +63,114 @@ internal class ConversationMessagesDelegateImpl @Inject constructor( return@collectLatest } - conversationsRepository - .getConversationMessages(conversationId = conversationId) - .map { messages -> - ConversationMessagesUiState.Present( - messages = messages - .asSequence() - .map(conversationMessageUiModelMapper::map) - .toImmutableList(), + observeConversationMessagesUiState( + conversationId = conversationId, + ).collect { currentMessagesUiState -> + _state.value = currentMessagesUiState + } + } + } + } + + @OptIn(ExperimentalCoroutinesApi::class) + private fun observeConversationMessagesUiState( + conversationId: String, + ): Flow { + return conversationsRepository + .getConversationMessages(conversationId = conversationId) + .map { messages -> + messages + .asSequence() + .map(conversationMessageUiModelMapper::map) + .toImmutableList() + } + .flatMapLatest { messages -> + observeConversationMessagesUiState( + messages = messages, + ) + } + .flowOn(defaultDispatcher) + } + + private fun observeConversationMessagesUiState( + messages: List, + ): Flow { + val vCardContentUris = messages + .asSequence() + .flatMap { message -> message.parts.asSequence() } + .mapNotNull { part -> + (part as? ConversationMessagePartUiModel.Attachment.VCard) + ?.contentUri + ?.toString() + } + .distinct() + .toList() + + if (vCardContentUris.isEmpty()) { + return flowOf( + ConversationMessagesUiState.Present( + messages = messages.toImmutableList(), + ), + ) + } + + val vCardAttachmentUiStateFlows = vCardContentUris.map { contentUri -> + conversationVCardMetadataRepository + .observeAttachmentMetadata(contentUri = contentUri) + .map { metadata -> + contentUri to metadata + } + } + + return combine(flows = vCardAttachmentUiStateFlows) { contentUriAndUiStates -> + val vCardAttachmentMetadata = contentUriAndUiStates.associate { pair -> + pair.first to pair.second + } + + ConversationMessagesUiState.Present( + messages = messages + .map { message -> + message.withVCardAttachmentMetadata( + vCardAttachmentMetadata = vCardAttachmentMetadata, ) } - .flowOn(defaultDispatcher) - .collect { currentMessagesUiState -> - _state.value = currentMessagesUiState - } - } + .toImmutableList(), + ) + } + } +} + +private fun ConversationMessageUiModel.withVCardAttachmentMetadata( + vCardAttachmentMetadata: Map, +): ConversationMessageUiModel { + return copy( + parts = parts.map { part -> + part.withVCardAttachmentMetadata( + vCardAttachmentMetadata = vCardAttachmentMetadata, + ) + }, + ) +} + +private fun ConversationMessagePartUiModel.withVCardAttachmentMetadata( + vCardAttachmentMetadata: Map, +): ConversationMessagePartUiModel { + return when (this) { + is ConversationMessagePartUiModel.Attachment.VCard -> { + val contentUri = contentUri?.toString() + + copy( + metadata = contentUri?.let(vCardAttachmentMetadata::get), + ) + } + + is ConversationMessagePartUiModel.Attachment.Audio, + is ConversationMessagePartUiModel.Attachment.File, + is ConversationMessagePartUiModel.Attachment.Image, + is ConversationMessagePartUiModel.Attachment.Video, + is ConversationMessagePartUiModel.Text, + -> { + this } } } diff --git a/src/com/android/messaging/ui/conversation/v2/messages/mapper/ConversationMessageUiModelMapper.kt b/src/com/android/messaging/ui/conversation/v2/messages/mapper/ConversationMessageUiModelMapper.kt index a0eeb125..17578413 100644 --- a/src/com/android/messaging/ui/conversation/v2/messages/mapper/ConversationMessageUiModelMapper.kt +++ b/src/com/android/messaging/ui/conversation/v2/messages/mapper/ConversationMessageUiModelMapper.kt @@ -6,6 +6,7 @@ import com.android.messaging.datamodel.data.MessagePartData import com.android.messaging.ui.conversation.v2.messages.model.message.ConversationMessagePartUiModel import com.android.messaging.ui.conversation.v2.messages.model.message.ConversationMessageUiModel import com.android.messaging.ui.conversation.v2.messages.model.message.ConversationMessageUiModel.Status +import com.android.messaging.util.ContentType import com.android.messaging.util.LogUtil import javax.inject.Inject @@ -46,13 +47,65 @@ internal class ConversationMessageUiModelMapperImpl @Inject constructor() : } private fun mapPart(part: MessagePartData): ConversationMessagePartUiModel { - return ConversationMessagePartUiModel( - contentType = part.contentType ?: "", - text = part.text, - contentUri = part.contentUri, - width = part.width, - height = part.height, - ) + val contentType = part.contentType ?: "" + + return when { + ContentType.isTextType(contentType) -> { + ConversationMessagePartUiModel.Text( + text = part.text.orEmpty(), + ) + } + + ContentType.isAudioType(contentType) -> { + ConversationMessagePartUiModel.Attachment.Audio( + text = part.text, + contentType = contentType, + contentUri = part.contentUri, + width = part.width, + height = part.height, + ) + } + + ContentType.isImageType(contentType) -> { + ConversationMessagePartUiModel.Attachment.Image( + text = part.text, + contentType = contentType, + contentUri = part.contentUri, + width = part.width, + height = part.height, + ) + } + + ContentType.isVCardType(contentType) -> { + ConversationMessagePartUiModel.Attachment.VCard( + text = part.text, + contentType = contentType, + contentUri = part.contentUri, + width = part.width, + height = part.height, + ) + } + + ContentType.isVideoType(contentType) -> { + ConversationMessagePartUiModel.Attachment.Video( + text = part.text, + contentType = contentType, + contentUri = part.contentUri, + width = part.width, + height = part.height, + ) + } + + else -> { + ConversationMessagePartUiModel.Attachment.File( + text = part.text, + contentType = contentType, + contentUri = part.contentUri, + width = part.width, + height = part.height, + ) + } + } } private fun mapStatus(javaStatus: Int): Status { diff --git a/src/com/android/messaging/ui/conversation/v2/messages/model/attachment/ConversationInlineAttachment.kt b/src/com/android/messaging/ui/conversation/v2/messages/model/attachment/ConversationInlineAttachment.kt index a1e43ec6..5df37718 100644 --- a/src/com/android/messaging/ui/conversation/v2/messages/model/attachment/ConversationInlineAttachment.kt +++ b/src/com/android/messaging/ui/conversation/v2/messages/model/attachment/ConversationInlineAttachment.kt @@ -3,19 +3,36 @@ package com.android.messaging.ui.conversation.v2.messages.model.attachment import androidx.compose.runtime.Immutable @Immutable -internal data class ConversationInlineAttachment( - val key: String, - val contentUri: String?, - val kind: ConversationInlineAttachmentKind, - val openAction: ConversationAttachmentOpenAction?, - val subtitleTextResId: Int?, - val titleText: String?, - val titleTextResId: Int?, -) +internal sealed interface ConversationInlineAttachment { + val key: String + val openAction: ConversationAttachmentOpenAction? -@Immutable -internal enum class ConversationInlineAttachmentKind { - AUDIO, - FILE, - VCARD, + @Immutable + data class Audio( + override val key: String, + val contentUri: String, + override val openAction: ConversationAttachmentOpenAction?, + val titleText: String?, + val titleTextResId: Int?, + ) : ConversationInlineAttachment + + @Immutable + data class File( + override val key: String, + override val openAction: ConversationAttachmentOpenAction?, + val subtitleTextResId: Int?, + val titleText: String?, + val titleTextResId: Int?, + ) : ConversationInlineAttachment + + @Immutable + data class VCard( + override val key: String, + val contentUri: String, + override val openAction: ConversationAttachmentOpenAction?, + val subtitleTextResId: Int?, + val titleText: String?, + val titleTextResId: Int?, + val metadata: ConversationVCardAttachmentMetadata?, + ) : ConversationInlineAttachment } diff --git a/src/com/android/messaging/ui/conversation/v2/messages/model/attachment/ConversationMessageAttachment.kt b/src/com/android/messaging/ui/conversation/v2/messages/model/attachment/ConversationMessageAttachment.kt index 0ca36cca..359cfd98 100644 --- a/src/com/android/messaging/ui/conversation/v2/messages/model/attachment/ConversationMessageAttachment.kt +++ b/src/com/android/messaging/ui/conversation/v2/messages/model/attachment/ConversationMessageAttachment.kt @@ -10,13 +10,13 @@ internal sealed interface ConversationMessageAttachment { @Immutable data class Media( override val key: String, - val part: ConversationMessagePartUiModel, + val part: ConversationMessagePartUiModel.Attachment, ) : ConversationMessageAttachment @Immutable data class Unsupported( override val key: String, - val part: ConversationMessagePartUiModel, + val part: ConversationMessagePartUiModel.Attachment, ) : ConversationMessageAttachment @Immutable diff --git a/src/com/android/messaging/ui/conversation/v2/messages/model/attachment/ConversationVCardAttachmentMetadata.kt b/src/com/android/messaging/ui/conversation/v2/messages/model/attachment/ConversationVCardAttachmentMetadata.kt new file mode 100644 index 00000000..a810e92d --- /dev/null +++ b/src/com/android/messaging/ui/conversation/v2/messages/model/attachment/ConversationVCardAttachmentMetadata.kt @@ -0,0 +1,24 @@ +package com.android.messaging.ui.conversation.v2.messages.model.attachment + +import androidx.compose.runtime.Immutable + +@Immutable +internal sealed interface ConversationVCardAttachmentMetadata { + + @Immutable + data object Missing : ConversationVCardAttachmentMetadata + + @Immutable + data object Loading : ConversationVCardAttachmentMetadata + + @Immutable + data object Failed : ConversationVCardAttachmentMetadata + + @Immutable + data class Loaded( + val type: ConversationVCardAttachmentType, + val displayName: String?, + val details: String?, + val locationAddress: String?, + ) : ConversationVCardAttachmentMetadata +} diff --git a/src/com/android/messaging/ui/conversation/v2/messages/model/attachment/ConversationVCardAttachmentUiState.kt b/src/com/android/messaging/ui/conversation/v2/messages/model/attachment/ConversationVCardAttachmentUiState.kt new file mode 100644 index 00000000..9165544a --- /dev/null +++ b/src/com/android/messaging/ui/conversation/v2/messages/model/attachment/ConversationVCardAttachmentUiState.kt @@ -0,0 +1,16 @@ +package com.android.messaging.ui.conversation.v2.messages.model.attachment + +import androidx.compose.runtime.Immutable + +@Immutable +internal data class ConversationVCardAttachmentUiState( + val type: ConversationVCardAttachmentType, + val title: String, + val subtitle: String?, +) + +@Immutable +internal enum class ConversationVCardAttachmentType { + CONTACT, + LOCATION, +} diff --git a/src/com/android/messaging/ui/conversation/v2/messages/model/message/ConversationMessagePartUiModel.kt b/src/com/android/messaging/ui/conversation/v2/messages/model/message/ConversationMessagePartUiModel.kt index 10cbd800..0f5d4b96 100644 --- a/src/com/android/messaging/ui/conversation/v2/messages/model/message/ConversationMessagePartUiModel.kt +++ b/src/com/android/messaging/ui/conversation/v2/messages/model/message/ConversationMessagePartUiModel.kt @@ -2,52 +2,73 @@ package com.android.messaging.ui.conversation.v2.messages.model.message import android.net.Uri import androidx.compose.runtime.Immutable -import com.android.messaging.util.ContentType +import com.android.messaging.ui.conversation.v2.messages.model.attachment.ConversationVCardAttachmentMetadata @Immutable -internal data class ConversationMessagePartUiModel( - val contentType: String, - val text: String?, - val contentUri: Uri?, - val width: Int, - val height: Int, -) { - val hasCaptionText: Boolean by lazy(mode = LazyThreadSafetyMode.NONE) { - !text.isNullOrBlank() - } +internal sealed interface ConversationMessagePartUiModel { + val text: String? - val hasRenderableContentUri: Boolean by lazy(mode = LazyThreadSafetyMode.NONE) { - contentUri != null - } + @Immutable + data class Text( + override val text: String, + ) : ConversationMessagePartUiModel - val isAudioAttachment: Boolean by lazy(mode = LazyThreadSafetyMode.NONE) { - ContentType.isAudioType(contentType) - } + val hasCaptionText: Boolean + get() { + return !text.isNullOrBlank() + } - val isImageAttachment: Boolean by lazy(mode = LazyThreadSafetyMode.NONE) { - ContentType.isImageType(contentType) - } + @Immutable + sealed interface Attachment : ConversationMessagePartUiModel { + val contentType: String + val contentUri: Uri? + val width: Int + val height: Int - val isMediaAttachment: Boolean by lazy(mode = LazyThreadSafetyMode.NONE) { - ContentType.isMediaType(contentType) - } + @Immutable + data class Audio( + override val text: String?, + override val contentType: String, + override val contentUri: Uri?, + override val width: Int, + override val height: Int, + ) : Attachment - val isSupportedAttachment: Boolean by lazy(mode = LazyThreadSafetyMode.NONE) { - isImageAttachment || - isVideoAttachment || - isAudioAttachment || - isVCardAttachment - } + @Immutable + data class File( + override val text: String?, + override val contentType: String, + override val contentUri: Uri?, + override val width: Int, + override val height: Int, + ) : Attachment - val isTextPart: Boolean by lazy(mode = LazyThreadSafetyMode.NONE) { - ContentType.isTextType(contentType) - } + @Immutable + data class Image( + override val text: String?, + override val contentType: String, + override val contentUri: Uri?, + override val width: Int, + override val height: Int, + ) : Attachment - val isVCardAttachment: Boolean by lazy(mode = LazyThreadSafetyMode.NONE) { - ContentType.isVCardType(contentType) - } + @Immutable + data class VCard( + override val text: String?, + override val contentType: String, + override val contentUri: Uri?, + override val width: Int, + override val height: Int, + val metadata: ConversationVCardAttachmentMetadata? = null, + ) : Attachment - val isVideoAttachment: Boolean by lazy(mode = LazyThreadSafetyMode.NONE) { - ContentType.isVideoType(contentType) + @Immutable + data class Video( + override val text: String?, + override val contentType: String, + override val contentUri: Uri?, + override val width: Int, + override val height: Int, + ) : Attachment } } diff --git a/src/com/android/messaging/ui/conversation/v2/messages/repository/ConversationVCardMetadataMapper.kt b/src/com/android/messaging/ui/conversation/v2/messages/repository/ConversationVCardMetadataMapper.kt new file mode 100644 index 00000000..07cd720b --- /dev/null +++ b/src/com/android/messaging/ui/conversation/v2/messages/repository/ConversationVCardMetadataMapper.kt @@ -0,0 +1,47 @@ +package com.android.messaging.ui.conversation.v2.messages.repository + +import com.android.messaging.datamodel.data.VCardContactItemData +import com.android.messaging.datamodel.media.VCardResourceEntry +import com.android.messaging.ui.conversation.v2.messages.model.attachment.ConversationVCardAttachmentMetadata +import com.android.messaging.ui.conversation.v2.messages.model.attachment.ConversationVCardAttachmentType +import javax.inject.Inject + +internal interface ConversationVCardMetadataMapper { + fun map(vCardContactItemData: VCardContactItemData): ConversationVCardAttachmentMetadata +} + +internal class ConversationVCardMetadataMapperImpl @Inject constructor() : + ConversationVCardMetadataMapper { + + override fun map( + vCardContactItemData: VCardContactItemData, + ): ConversationVCardAttachmentMetadata { + val firstEntry = vCardContactItemData + .vCardResource + ?.vCards + ?.singleOrNull() + + val isLocation = firstEntry + ?.getKind() + ?.equals( + VCardResourceEntry.KIND_LOCATION, + ignoreCase = true, + ) == true + + return ConversationVCardAttachmentMetadata.Loaded( + type = when { + isLocation -> ConversationVCardAttachmentType.LOCATION + else -> ConversationVCardAttachmentType.CONTACT + }, + displayName = vCardContactItemData + .displayName + ?.takeIf { title -> title.isNotBlank() }, + details = vCardContactItemData + .details + ?.takeIf { subtitle -> subtitle.isNotBlank() }, + locationAddress = firstEntry + ?.displayAddress + ?.takeIf { subtitle -> subtitle.isNotBlank() }, + ) + } +} diff --git a/src/com/android/messaging/ui/conversation/v2/messages/repository/ConversationVCardMetadataRepository.kt b/src/com/android/messaging/ui/conversation/v2/messages/repository/ConversationVCardMetadataRepository.kt new file mode 100644 index 00000000..19b72b04 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/v2/messages/repository/ConversationVCardMetadataRepository.kt @@ -0,0 +1,71 @@ +package com.android.messaging.ui.conversation.v2.messages.repository + +import android.content.Context +import androidx.core.net.toUri +import com.android.messaging.datamodel.DataModel +import com.android.messaging.datamodel.data.PersonItemData +import com.android.messaging.datamodel.data.VCardContactItemData +import com.android.messaging.ui.conversation.v2.messages.model.attachment.ConversationVCardAttachmentMetadata +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.flowOf + +internal interface ConversationVCardMetadataRepository { + fun observeAttachmentMetadata( + contentUri: String?, + ): Flow +} + +internal class ConversationVCardMetadataRepositoryImpl @Inject constructor( + @param:ApplicationContext + private val context: Context, + private val conversationVCardMetadataMapper: ConversationVCardMetadataMapper, +) : ConversationVCardMetadataRepository { + + private val dataModel = DataModel.get() + + override fun observeAttachmentMetadata( + contentUri: String?, + ): Flow { + if (contentUri.isNullOrBlank()) { + return flowOf(ConversationVCardAttachmentMetadata.Missing) + } + + return callbackFlow { + trySend(ConversationVCardAttachmentMetadata.Loading) + + val vCardData = dataModel.createVCardContactItemData( + context, + contentUri.toUri(), + ) + val bindingId = "conversation-vcard-inline:$contentUri" + val listener = object : PersonItemData.PersonItemDataListener { + override fun onPersonDataUpdated(data: PersonItemData) { + val typedData = data as? VCardContactItemData ?: return + trySend( + conversationVCardMetadataMapper.map( + vCardContactItemData = typedData, + ), + ) + } + + override fun onPersonDataFailed( + data: PersonItemData, + exception: Exception, + ) { + trySend(ConversationVCardAttachmentMetadata.Failed) + } + } + + vCardData.bind(bindingId) + vCardData.setListener(listener) + + awaitClose { + vCardData.unbind(bindingId) + } + } + } +} diff --git a/src/com/android/messaging/ui/conversation/v2/messages/ui/attachment/ConversationAttachmentSectionsBuilder.kt b/src/com/android/messaging/ui/conversation/v2/messages/ui/attachment/ConversationAttachmentSectionsBuilder.kt index a511480d..fdd514ca 100644 --- a/src/com/android/messaging/ui/conversation/v2/messages/ui/attachment/ConversationAttachmentSectionsBuilder.kt +++ b/src/com/android/messaging/ui/conversation/v2/messages/ui/attachment/ConversationAttachmentSectionsBuilder.kt @@ -5,8 +5,9 @@ import com.android.messaging.ui.conversation.v2.messages.model.attachment.Conver import com.android.messaging.ui.conversation.v2.messages.model.attachment.ConversationAttachmentOpenAction import com.android.messaging.ui.conversation.v2.messages.model.attachment.ConversationAttachmentSections import com.android.messaging.ui.conversation.v2.messages.model.attachment.ConversationInlineAttachment -import com.android.messaging.ui.conversation.v2.messages.model.attachment.ConversationInlineAttachmentKind import com.android.messaging.ui.conversation.v2.messages.model.attachment.ConversationMessageAttachment +import com.android.messaging.ui.conversation.v2.messages.model.attachment.ConversationVCardAttachmentMetadata +import com.android.messaging.ui.conversation.v2.messages.model.message.ConversationMessagePartUiModel import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList @@ -34,7 +35,8 @@ private fun isGalleryVisualAttachment( attachment: ConversationMessageAttachment, ): Boolean { return when (attachment) { - is ConversationMessageAttachment.Media -> attachment.part.isImageAttachment + is ConversationMessageAttachment.Media -> + attachment.part is ConversationMessagePartUiModel.Attachment.Image is ConversationMessageAttachment.YouTubePreview -> true is ConversationMessageAttachment.Unsupported -> false } @@ -44,7 +46,8 @@ private fun isStandaloneVisualAttachment( attachment: ConversationMessageAttachment, ): Boolean { return when (attachment) { - is ConversationMessageAttachment.Media -> attachment.part.isVideoAttachment + is ConversationMessageAttachment.Media -> + attachment.part is ConversationMessagePartUiModel.Attachment.Video is ConversationMessageAttachment.Unsupported, is ConversationMessageAttachment.YouTubePreview, @@ -114,28 +117,32 @@ private fun toInlineAttachment( private fun toMediaInlineAttachment( attachment: ConversationMessageAttachment.Media, ): ConversationInlineAttachment? { - return when { - attachment.part.isAudioAttachment -> { + return when (val part = attachment.part) { + is ConversationMessagePartUiModel.Attachment.Audio -> { createAudioInlineAttachment( key = attachment.key, - contentUri = attachment.part.contentUri.toString(), + contentUri = part.contentUri.toString(), openAction = attachment.toConversationAttachmentOpenActionOrNull(), ) } - attachment.part.isVCardAttachment -> { + is ConversationMessagePartUiModel.Attachment.VCard -> { createVCardInlineAttachment( key = attachment.key, + contentUri = part.contentUri.toString(), openAction = attachment.toConversationAttachmentOpenActionOrNull(), + vCardAttachmentMetadata = part.metadata, ) } - attachment.part.isImageAttachment || attachment.part.isVideoAttachment -> null + is ConversationMessagePartUiModel.Attachment.Image, + is ConversationMessagePartUiModel.Attachment.Video, + -> null - else -> { + is ConversationMessagePartUiModel.Attachment.File -> { createFileInlineAttachment( key = attachment.key, - titleText = attachment.part.contentType.ifBlank { null }, + titleText = part.contentType.ifBlank { null }, openAction = attachment.toConversationAttachmentOpenActionOrNull(), ) } @@ -147,12 +154,10 @@ private fun createAudioInlineAttachment( contentUri: String, openAction: ConversationAttachmentOpenAction?, ): ConversationInlineAttachment { - return ConversationInlineAttachment( + return ConversationInlineAttachment.Audio( key = key, contentUri = contentUri, - kind = ConversationInlineAttachmentKind.AUDIO, openAction = openAction, - subtitleTextResId = null, titleText = null, titleTextResId = R.string.audio_attachment_content_description, ) @@ -160,16 +165,18 @@ private fun createAudioInlineAttachment( private fun createVCardInlineAttachment( key: String, + contentUri: String, openAction: ConversationAttachmentOpenAction?, + vCardAttachmentMetadata: ConversationVCardAttachmentMetadata?, ): ConversationInlineAttachment { - return ConversationInlineAttachment( + return ConversationInlineAttachment.VCard( key = key, - contentUri = null, - kind = ConversationInlineAttachmentKind.VCARD, + contentUri = contentUri, openAction = openAction, subtitleTextResId = R.string.vcard_tap_hint, titleText = null, titleTextResId = R.string.notification_vcard, + metadata = vCardAttachmentMetadata, ) } @@ -178,10 +185,8 @@ private fun createFileInlineAttachment( titleText: String?, openAction: ConversationAttachmentOpenAction?, ): ConversationInlineAttachment { - return ConversationInlineAttachment( + return ConversationInlineAttachment.File( key = key, - contentUri = null, - kind = ConversationInlineAttachmentKind.FILE, openAction = openAction, subtitleTextResId = null, titleText = titleText, diff --git a/src/com/android/messaging/ui/conversation/v2/messages/ui/attachment/ConversationGenericInlineAttachmentRow.kt b/src/com/android/messaging/ui/conversation/v2/messages/ui/attachment/ConversationGenericInlineAttachmentRow.kt index 63864534..d1edc034 100644 --- a/src/com/android/messaging/ui/conversation/v2/messages/ui/attachment/ConversationGenericInlineAttachmentRow.kt +++ b/src/com/android/messaging/ui/conversation/v2/messages/ui/attachment/ConversationGenericInlineAttachmentRow.kt @@ -11,8 +11,6 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Description -import androidx.compose.material.icons.rounded.Person -import androidx.compose.material.icons.rounded.PlayArrow import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface @@ -23,13 +21,11 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp -import com.android.messaging.R import com.android.messaging.ui.conversation.v2.messages.model.attachment.ConversationInlineAttachment -import com.android.messaging.ui.conversation.v2.messages.model.attachment.ConversationInlineAttachmentKind @Composable internal fun ConversationGenericInlineAttachmentRow( - attachment: ConversationInlineAttachment, + attachment: ConversationInlineAttachment.File, onAttachmentClick: (contentType: String, contentUri: String) -> Unit, onExternalUriClick: (String) -> Unit, onLongClick: () -> Unit, @@ -77,9 +73,7 @@ internal fun ConversationGenericInlineAttachmentRow( modifier = Modifier.size(size = 28.dp), contentAlignment = Alignment.Center, ) { - ConversationInlineAttachmentIcon( - kind = attachment.kind, - ) + ConversationFileInlineAttachmentIcon() } Column( @@ -104,31 +98,9 @@ internal fun ConversationGenericInlineAttachmentRow( } @Composable -private fun ConversationInlineAttachmentIcon( - kind: ConversationInlineAttachmentKind, -) { - when (kind) { - ConversationInlineAttachmentKind.AUDIO -> { - Icon( - imageVector = Icons.Rounded.PlayArrow, - contentDescription = stringResource( - id = R.string.audio_play_content_description, - ), - ) - } - - ConversationInlineAttachmentKind.FILE -> { - Icon( - imageVector = Icons.Rounded.Description, - contentDescription = null, - ) - } - - ConversationInlineAttachmentKind.VCARD -> { - Icon( - imageVector = Icons.Rounded.Person, - contentDescription = null, - ) - } - } +private fun ConversationFileInlineAttachmentIcon() { + Icon( + imageVector = Icons.Rounded.Description, + contentDescription = null, + ) } diff --git a/src/com/android/messaging/ui/conversation/v2/messages/ui/attachment/ConversationInlineAttachmentRow.kt b/src/com/android/messaging/ui/conversation/v2/messages/ui/attachment/ConversationInlineAttachmentRow.kt index 2f38dce1..baedc9f6 100644 --- a/src/com/android/messaging/ui/conversation/v2/messages/ui/attachment/ConversationInlineAttachmentRow.kt +++ b/src/com/android/messaging/ui/conversation/v2/messages/ui/attachment/ConversationInlineAttachmentRow.kt @@ -2,7 +2,6 @@ package com.android.messaging.ui.conversation.v2.messages.ui.attachment import androidx.compose.runtime.Composable import com.android.messaging.ui.conversation.v2.messages.model.attachment.ConversationInlineAttachment -import com.android.messaging.ui.conversation.v2.messages.model.attachment.ConversationInlineAttachmentKind @Composable internal fun ConversationInlineAttachmentRow( @@ -14,11 +13,8 @@ internal fun ConversationInlineAttachmentRow( onExternalUriClick: (String) -> Unit, onLongClick: () -> Unit = {}, ) { - val shouldUseEmbeddedAudioPlayer = attachment.kind == ConversationInlineAttachmentKind.AUDIO && - !attachment.contentUri.isNullOrBlank() - - when { - shouldUseEmbeddedAudioPlayer -> { + when (attachment) { + is ConversationInlineAttachment.Audio -> { ConversationInlineAudioAttachmentRow( attachment = attachment, isIncoming = isIncoming, @@ -28,7 +24,17 @@ internal fun ConversationInlineAttachmentRow( ) } - else -> { + is ConversationInlineAttachment.VCard -> { + ConversationVCardInlineAttachmentRow( + attachment = attachment, + isSelectionMode = isSelectionMode, + onAttachmentClick = onAttachmentClick, + onExternalUriClick = onExternalUriClick, + onLongClick = onLongClick, + ) + } + + is ConversationInlineAttachment.File -> { ConversationGenericInlineAttachmentRow( attachment = attachment, onAttachmentClick = onAttachmentClick, diff --git a/src/com/android/messaging/ui/conversation/v2/messages/ui/attachment/ConversationInlineAudioAttachmentRow.kt b/src/com/android/messaging/ui/conversation/v2/messages/ui/attachment/ConversationInlineAudioAttachmentRow.kt index 70e379fa..48a6d392 100644 --- a/src/com/android/messaging/ui/conversation/v2/messages/ui/attachment/ConversationInlineAudioAttachmentRow.kt +++ b/src/com/android/messaging/ui/conversation/v2/messages/ui/attachment/ConversationInlineAudioAttachmentRow.kt @@ -44,14 +44,14 @@ private val AUDIO_ATTACHMENT_HEIGHT = 70.dp @Composable internal fun ConversationInlineAudioAttachmentRow( - attachment: ConversationInlineAttachment, + attachment: ConversationInlineAttachment.Audio, isIncoming: Boolean, isSelectionMode: Boolean, useStandaloneAudioAttachmentBackground: Boolean, onLongClick: () -> Unit, ) { val context = LocalContext.current - val contentUri = attachment.contentUri ?: return + val contentUri = attachment.contentUri val title = attachment.titleText ?: attachment.titleTextResId?.let { stringResource(it) } diff --git a/src/com/android/messaging/ui/conversation/v2/messages/ui/attachment/ConversationVCardInlineAttachmentRow.kt b/src/com/android/messaging/ui/conversation/v2/messages/ui/attachment/ConversationVCardInlineAttachmentRow.kt new file mode 100644 index 00000000..82efe5c1 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/v2/messages/ui/attachment/ConversationVCardInlineAttachmentRow.kt @@ -0,0 +1,228 @@ +package com.android.messaging.ui.conversation.v2.messages.ui.attachment + +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 +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Person +import androidx.compose.material.icons.rounded.Place +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.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.android.messaging.R +import com.android.messaging.ui.conversation.v2.messages.model.attachment.ConversationInlineAttachment +import com.android.messaging.ui.conversation.v2.messages.model.attachment.ConversationVCardAttachmentMetadata +import com.android.messaging.ui.conversation.v2.messages.model.attachment.ConversationVCardAttachmentType +import com.android.messaging.ui.conversation.v2.messages.model.attachment.ConversationVCardAttachmentUiState + +@Composable +internal fun ConversationVCardInlineAttachmentRow( + attachment: ConversationInlineAttachment.VCard, + isSelectionMode: Boolean, + onAttachmentClick: (contentType: String, contentUri: String) -> Unit, + onExternalUriClick: (String) -> Unit, + onLongClick: () -> Unit, +) { + val uiState = attachment.toConversationVCardAttachmentUiState() + + val onClick = attachment.openAction?.let { action -> + { + dispatchConversationAttachmentOpenAction( + action = action, + onAttachmentClick = onAttachmentClick, + onExternalUriClick = onExternalUriClick, + ) + } + } + + ConversationVCardInlineAttachmentRowContent( + uiState = uiState, + isSelectionMode = isSelectionMode, + onClick = onClick, + onLongClick = onLongClick, + ) +} + +@Composable +private fun ConversationInlineAttachment.VCard.toConversationVCardAttachmentUiState(): + ConversationVCardAttachmentUiState { + return metadata.toConversationVCardAttachmentUiState( + defaultUiText = resolveConversationVCardDefaultUiText(), + ) +} + +@Composable +private fun ConversationInlineAttachment.VCard.resolveConversationVCardDefaultUiText(): + ConversationVCardDefaultUiText { + val defaultTitle = titleText + ?: titleTextResId?.let { titleTextResId -> + stringResource(id = titleTextResId) + } + ?: stringResource(id = R.string.notification_vcard) + + val defaultSubtitle = subtitleTextResId?.let { subtitleTextResId -> + stringResource(id = subtitleTextResId) + } ?: stringResource(id = R.string.vcard_tap_hint) + + return ConversationVCardDefaultUiText( + defaultTitle = defaultTitle, + defaultSubtitle = defaultSubtitle, + loadingSubtitle = stringResource(id = R.string.loading_vcard), + failedSubtitle = stringResource(id = R.string.failed_loading_vcard), + locationTitle = stringResource(id = R.string.notification_location), + ) +} + +private fun ConversationVCardAttachmentMetadata?.toConversationVCardAttachmentUiState( + defaultUiText: ConversationVCardDefaultUiText, +): ConversationVCardAttachmentUiState { + return when (this) { + ConversationVCardAttachmentMetadata.Failed -> { + createConversationContactUiState( + title = defaultUiText.defaultTitle, + subtitle = defaultUiText.failedSubtitle, + ) + } + + ConversationVCardAttachmentMetadata.Loading -> { + createConversationContactUiState( + title = defaultUiText.defaultTitle, + subtitle = defaultUiText.loadingSubtitle, + ) + } + + ConversationVCardAttachmentMetadata.Missing, + null, + -> { + createConversationContactUiState( + title = defaultUiText.defaultTitle, + subtitle = defaultUiText.defaultSubtitle, + ) + } + + is ConversationVCardAttachmentMetadata.Loaded -> { + toConversationLoadedVCardAttachmentUiState( + defaultUiText = defaultUiText, + ) + } + } +} + +private fun ConversationVCardAttachmentMetadata.Loaded.toConversationLoadedVCardAttachmentUiState( + defaultUiText: ConversationVCardDefaultUiText, +): ConversationVCardAttachmentUiState { + return when (type) { + ConversationVCardAttachmentType.CONTACT -> { + createConversationContactUiState( + title = displayName ?: defaultUiText.defaultTitle, + subtitle = details ?: defaultUiText.defaultSubtitle, + ) + } + + ConversationVCardAttachmentType.LOCATION -> { + ConversationVCardAttachmentUiState( + type = ConversationVCardAttachmentType.LOCATION, + title = displayName ?: defaultUiText.locationTitle, + subtitle = locationAddress ?: details, + ) + } + } +} + +private fun createConversationContactUiState( + title: String, + subtitle: String?, +): ConversationVCardAttachmentUiState { + return ConversationVCardAttachmentUiState( + type = ConversationVCardAttachmentType.CONTACT, + title = title, + subtitle = subtitle, + ) +} + +@Composable +internal fun ConversationVCardInlineAttachmentRowContent( + uiState: ConversationVCardAttachmentUiState, + isSelectionMode: Boolean, + onClick: (() -> Unit)?, + onLongClick: () -> Unit, +) { + val modifier = when { + isSelectionMode -> Modifier + else -> { + Modifier.combinedClickable( + onClick = { + onClick?.invoke() + }, + onLongClick = onLongClick, + ) + } + } + + Surface( + modifier = Modifier + .fillMaxWidth() + .then(other = modifier), + color = MaterialTheme.colorScheme.surfaceContainerHighest, + shape = RoundedCornerShape(size = MESSAGE_ATTACHMENT_CORNER_RADIUS), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 14.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.spacedBy(space = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier.size(size = 28.dp), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = when (uiState.type) { + ConversationVCardAttachmentType.CONTACT -> Icons.Rounded.Person + ConversationVCardAttachmentType.LOCATION -> Icons.Rounded.Place + }, + contentDescription = null, + ) + } + + Column( + verticalArrangement = Arrangement.spacedBy(space = 2.dp), + ) { + Text( + text = uiState.title, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + + uiState.subtitle?.let { subtitle -> + Text( + text = subtitle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } +} + +private data class ConversationVCardDefaultUiText( + val defaultTitle: String, + val defaultSubtitle: String, + val loadingSubtitle: String, + val failedSubtitle: String, + val locationTitle: String, +) diff --git a/src/com/android/messaging/ui/conversation/v2/messages/ui/attachment/ConversationVisualAttachments.kt b/src/com/android/messaging/ui/conversation/v2/messages/ui/attachment/ConversationVisualAttachments.kt index d4dd0d02..3244bf0a 100644 --- a/src/com/android/messaging/ui/conversation/v2/messages/ui/attachment/ConversationVisualAttachments.kt +++ b/src/com/android/messaging/ui/conversation/v2/messages/ui/attachment/ConversationVisualAttachments.kt @@ -348,7 +348,9 @@ private fun BoxScope.CenterPlayAffordance() { private fun ConversationMessageAttachment.requiresPlaybackAffordance(): Boolean { return when (this) { - is ConversationMessageAttachment.Media -> part.isVideoAttachment + is ConversationMessageAttachment.Media -> { + part is ConversationMessagePartUiModel.Attachment.Video + } is ConversationMessageAttachment.YouTubePreview -> true is ConversationMessageAttachment.Unsupported -> false } @@ -378,13 +380,19 @@ private fun resolveAttachmentAspectRatio( } private fun resolvePartAspectRatio( - part: ConversationMessagePartUiModel, + part: ConversationMessagePartUiModel.Attachment, ): Float { val hasMeasuredSize = part.width > 0 && part.height > 0 return when { - hasMeasuredSize -> part.width.toFloat() / part.height.toFloat() - part.isVideoAttachment -> MESSAGE_ATTACHMENT_DEFAULT_VIDEO_ASPECT_RATIO + hasMeasuredSize -> { + part.width.toFloat() / part.height.toFloat() + } + + part is ConversationMessagePartUiModel.Attachment.Video -> { + MESSAGE_ATTACHMENT_DEFAULT_VIDEO_ASPECT_RATIO + } + else -> MESSAGE_ATTACHMENT_DEFAULT_IMAGE_ASPECT_RATIO } } diff --git a/src/com/android/messaging/ui/conversation/v2/messages/ui/message/ConversationMessageContentBuilder.kt b/src/com/android/messaging/ui/conversation/v2/messages/ui/message/ConversationMessageContentBuilder.kt index 4f0ac245..e6d39b28 100644 --- a/src/com/android/messaging/ui/conversation/v2/messages/ui/message/ConversationMessageContentBuilder.kt +++ b/src/com/android/messaging/ui/conversation/v2/messages/ui/message/ConversationMessageContentBuilder.kt @@ -46,7 +46,8 @@ private fun buildConversationMessageAttachments( .toImmutableList() val hasImageAttachment = attachmentItems.any { attachment -> - attachment is ConversationMessageAttachment.Media && attachment.part.isImageAttachment + attachment is ConversationMessageAttachment.Media && + attachment.part is ConversationMessagePartUiModel.Attachment.Image } if (hasImageAttachment) { @@ -65,28 +66,26 @@ private fun toConversationMessageAttachment( index: Int, part: ConversationMessagePartUiModel, ): ConversationMessageAttachment? { - if (!part.isMediaAttachment) { - return null - } + val attachmentPart = part as? ConversationMessagePartUiModel.Attachment ?: return null val key = buildConversationMessageAttachmentKey( index = index, - contentType = part.contentType, - contentUri = part.contentUri, + contentType = attachmentPart.contentType, + contentUri = attachmentPart.contentUri, ) return when { - part.isSupportedAttachment && part.hasRenderableContentUri -> { + attachmentPart.isSupportedAttachment() && attachmentPart.contentUri != null -> { ConversationMessageAttachment.Media( key = key, - part = part, + part = attachmentPart, ) } else -> { ConversationMessageAttachment.Unsupported( key = key, - part = part, + part = attachmentPart, ) } } @@ -119,7 +118,7 @@ private fun buildConversationMessageBodyText( val captionText = message.parts .asSequence() - .filter { part -> part.hasCaptionText } + .filter { it.hasCaptionText } .mapNotNull { part -> part.text?.trim()?.takeIf { text -> text.isNotEmpty() } } @@ -133,6 +132,18 @@ private fun buildConversationMessageBodyText( } } +private fun ConversationMessagePartUiModel.Attachment.isSupportedAttachment(): Boolean { + return when (this) { + is ConversationMessagePartUiModel.Attachment.Audio, + is ConversationMessagePartUiModel.Attachment.Image, + is ConversationMessagePartUiModel.Attachment.VCard, + is ConversationMessagePartUiModel.Attachment.Video, + -> true + + is ConversationMessagePartUiModel.Attachment.File -> false + } +} + private fun findSingleYouTubePreview( text: String, ): ConversationMessageAttachment.YouTubePreview? { From bb76b64b92d04fcf6da13a9a9fa99291b0d51526 Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Wed, 22 Apr 2026 13:35:00 +0300 Subject: [PATCH 52/99] Add richer attachment handling for conversation compose UI --- .../conversation/ConversationBindsModule.kt | 35 ++- .../ConversationViewModelBindsModule.kt | 8 + .../conversation/v2/ConversationTestTags.kt | 4 + ...ConversationComposerAttachmentsDelegate.kt | 192 +++++++++++++++ ...ersationComposerAttachmentUiModelMapper.kt | 99 ++++++++ .../ConversationComposerUiStateMapper.kt | 32 +-- .../model/ComposerAttachmentUiModel.kt | 72 ++++++ .../ConversationComposerAttachmentUiState.kt | 28 --- .../model/ConversationComposerUiState.kt | 2 +- .../ui/ConversationAttachmentPreview.kt | 126 ++++++++-- .../v2/composer/ui/ConversationComposeBar.kt | 105 +++++++-- .../ui/ConversationComposerSection.kt | 13 +- .../ConversationAttachmentBridge.kt | 82 ------- .../v2/mediapicker/ConversationMediaPicker.kt | 16 +- .../ConversationMediaPickerDelegate.kt | 32 ++- .../ConversationMediaPickerOverlay.kt | 6 +- .../ConversationMediaPickerScaffold.kt | 12 +- .../review/ConversationMediaPickerReview.kt | 16 +- .../ConversationMediaReviewBackground.kt | 14 +- .../review/ConversationMediaReviewPageCard.kt | 21 +- .../ConversationMediaReviewPagerState.kt | 14 +- .../ConversationDraftAttachmentMapper.kt | 34 +++ .../ConversationAttachmentRepository.kt | 111 +++++++++ .../delegate/ConversationMessagesDelegate.kt | 98 ++++---- .../ConversationMessageUiModelMapper.kt | 8 +- ...onversationVCardAttachmentUiModelMapper.kt | 134 +++++++++++ .../ConversationInlineAttachment.kt | 5 +- ... => ConversationVCardAttachmentUiModel.kt} | 8 +- .../message/ConversationMessagePartUiModel.kt | 4 +- .../ConversationAttachmentSectionsBuilder.kt | 15 +- .../ConversationVCardInlineAttachmentRow.kt | 221 +++++++----------- .../v2/screen/ConversationScreen.kt | 24 +- .../v2/screen/ConversationViewModel.kt | 95 ++++---- .../ConversationMediaPickerOverlayUiState.kt | 4 +- 34 files changed, 1199 insertions(+), 491 deletions(-) create mode 100644 src/com/android/messaging/ui/conversation/v2/composer/delegate/ConversationComposerAttachmentsDelegate.kt create mode 100644 src/com/android/messaging/ui/conversation/v2/composer/mapper/ConversationComposerAttachmentUiModelMapper.kt create mode 100644 src/com/android/messaging/ui/conversation/v2/composer/model/ComposerAttachmentUiModel.kt delete mode 100644 src/com/android/messaging/ui/conversation/v2/composer/model/ConversationComposerAttachmentUiState.kt delete mode 100644 src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationAttachmentBridge.kt create mode 100644 src/com/android/messaging/ui/conversation/v2/mediapicker/mapper/ConversationDraftAttachmentMapper.kt create mode 100644 src/com/android/messaging/ui/conversation/v2/mediapicker/repository/ConversationAttachmentRepository.kt create mode 100644 src/com/android/messaging/ui/conversation/v2/messages/mapper/ConversationVCardAttachmentUiModelMapper.kt rename src/com/android/messaging/ui/conversation/v2/messages/model/attachment/{ConversationVCardAttachmentUiState.kt => ConversationVCardAttachmentUiModel.kt} (57%) diff --git a/src/com/android/messaging/di/conversation/ConversationBindsModule.kt b/src/com/android/messaging/di/conversation/ConversationBindsModule.kt index 4d91df6a..683a1c38 100644 --- a/src/com/android/messaging/di/conversation/ConversationBindsModule.kt +++ b/src/com/android/messaging/di/conversation/ConversationBindsModule.kt @@ -36,12 +36,18 @@ import com.android.messaging.domain.conversation.usecase.ResolveConversationId import com.android.messaging.domain.conversation.usecase.ResolveConversationIdImpl import com.android.messaging.domain.conversation.usecase.SendConversationDraft import com.android.messaging.domain.conversation.usecase.SendConversationDraftImpl +import com.android.messaging.ui.conversation.v2.composer.mapper.ConversationComposerAttachmentUiModelMapper +import com.android.messaging.ui.conversation.v2.composer.mapper.ConversationComposerAttachmentUiModelMapperImpl import com.android.messaging.ui.conversation.v2.composer.mapper.ConversationComposerUiStateMapper import com.android.messaging.ui.conversation.v2.composer.mapper.ConversationComposerUiStateMapperImpl -import com.android.messaging.ui.conversation.v2.mediapicker.ConversationAttachmentBridge -import com.android.messaging.ui.conversation.v2.mediapicker.ConversationAttachmentBridgeImpl +import com.android.messaging.ui.conversation.v2.mediapicker.mapper.ConversationDraftAttachmentMapper +import com.android.messaging.ui.conversation.v2.mediapicker.mapper.ConversationDraftAttachmentMapperImpl +import com.android.messaging.ui.conversation.v2.mediapicker.repository.ConversationAttachmentRepository +import com.android.messaging.ui.conversation.v2.mediapicker.repository.ConversationAttachmentRepositoryImpl import com.android.messaging.ui.conversation.v2.messages.mapper.ConversationMessageUiModelMapper import com.android.messaging.ui.conversation.v2.messages.mapper.ConversationMessageUiModelMapperImpl +import com.android.messaging.ui.conversation.v2.messages.mapper.ConversationVCardAttachmentUiModelMapper +import com.android.messaging.ui.conversation.v2.messages.mapper.ConversationVCardAttachmentUiModelMapperImpl import com.android.messaging.ui.conversation.v2.messages.repository.ConversationVCardMetadataMapper import com.android.messaging.ui.conversation.v2.messages.repository.ConversationVCardMetadataMapperImpl import com.android.messaging.ui.conversation.v2.messages.repository.ConversationVCardMetadataRepository @@ -155,9 +161,22 @@ internal abstract class ConversationBindsModule { ): ConversationSubscriptionsRepository @Binds - abstract fun bindConversationAttachmentBridge( - impl: ConversationAttachmentBridgeImpl, - ): ConversationAttachmentBridge + @Reusable + abstract fun bindConversationAttachmentRepository( + impl: ConversationAttachmentRepositoryImpl, + ): ConversationAttachmentRepository + + @Binds + @Reusable + abstract fun bindConversationDraftAttachmentMapper( + impl: ConversationDraftAttachmentMapperImpl, + ): ConversationDraftAttachmentMapper + + @Binds + @Reusable + abstract fun bindConversationComposerAttachmentUiModelMapper( + impl: ConversationComposerAttachmentUiModelMapperImpl, + ): ConversationComposerAttachmentUiModelMapper @Binds abstract fun bindConversationComposerUiStateMapper( @@ -169,6 +188,12 @@ internal abstract class ConversationBindsModule { impl: ConversationMessageUiModelMapperImpl, ): ConversationMessageUiModelMapper + @Binds + @Reusable + abstract fun bindConversationVCardAttachmentUiModelMapper( + impl: ConversationVCardAttachmentUiModelMapperImpl, + ): ConversationVCardAttachmentUiModelMapper + @Binds @Reusable abstract fun bindConversationVCardMetadataRepository( diff --git a/src/com/android/messaging/di/conversation/ConversationViewModelBindsModule.kt b/src/com/android/messaging/di/conversation/ConversationViewModelBindsModule.kt index a6e39323..733afb2b 100644 --- a/src/com/android/messaging/di/conversation/ConversationViewModelBindsModule.kt +++ b/src/com/android/messaging/di/conversation/ConversationViewModelBindsModule.kt @@ -1,5 +1,7 @@ package com.android.messaging.di.conversation +import com.android.messaging.ui.conversation.v2.composer.delegate.ConversationComposerAttachmentsDelegate +import com.android.messaging.ui.conversation.v2.composer.delegate.ConversationComposerAttachmentsDelegateImpl import com.android.messaging.ui.conversation.v2.composer.delegate.ConversationDraftDelegate import com.android.messaging.ui.conversation.v2.composer.delegate.ConversationDraftDelegateImpl import com.android.messaging.ui.conversation.v2.mediapicker.ConversationMediaPickerDelegate @@ -22,6 +24,12 @@ import dagger.hilt.android.scopes.ViewModelScoped @InstallIn(ViewModelComponent::class) internal abstract class ConversationViewModelBindsModule { + @Binds + @ViewModelScoped + abstract fun bindConversationComposerAttachmentsDelegate( + impl: ConversationComposerAttachmentsDelegateImpl, + ): ConversationComposerAttachmentsDelegate + @Binds @ViewModelScoped abstract fun bindConversationDraftDelegate( diff --git a/src/com/android/messaging/ui/conversation/v2/ConversationTestTags.kt b/src/com/android/messaging/ui/conversation/v2/ConversationTestTags.kt index 4f1a7e2e..541e6340 100644 --- a/src/com/android/messaging/ui/conversation/v2/ConversationTestTags.kt +++ b/src/com/android/messaging/ui/conversation/v2/ConversationTestTags.kt @@ -5,6 +5,10 @@ import androidx.compose.ui.semantics.SemanticsPropertyReceiver internal const val CONVERSATION_COMPOSE_BAR_TEST_TAG = "conversation_compose_bar" internal const val CONVERSATION_ATTACHMENT_BUTTON_TEST_TAG = "conversation_attachment_button" +internal const val CONVERSATION_ATTACHMENT_CONTACT_MENU_ITEM_TEST_TAG = + "conversation_attachment_contact_menu_item" +internal const val CONVERSATION_ATTACHMENT_MEDIA_MENU_ITEM_TEST_TAG = + "conversation_attachment_media_menu_item" internal const val CONVERSATION_ATTACHMENT_PREVIEW_LIST_TEST_TAG = "conversation_attachment_preview_list" internal const val CONVERSATION_ADD_PEOPLE_BUTTON_TEST_TAG = "conversation_add_people_button" diff --git a/src/com/android/messaging/ui/conversation/v2/composer/delegate/ConversationComposerAttachmentsDelegate.kt b/src/com/android/messaging/ui/conversation/v2/composer/delegate/ConversationComposerAttachmentsDelegate.kt new file mode 100644 index 00000000..7ba802df --- /dev/null +++ b/src/com/android/messaging/ui/conversation/v2/composer/delegate/ConversationComposerAttachmentsDelegate.kt @@ -0,0 +1,192 @@ +package com.android.messaging.ui.conversation.v2.composer.delegate + +import com.android.messaging.data.conversation.model.draft.ConversationDraftAttachment +import com.android.messaging.data.conversation.model.draft.ConversationDraftPendingAttachment +import com.android.messaging.di.core.DefaultDispatcher +import com.android.messaging.ui.conversation.v2.composer.mapper.ConversationComposerAttachmentUiModelMapper +import com.android.messaging.ui.conversation.v2.composer.model.ComposerAttachmentUiModel +import com.android.messaging.ui.conversation.v2.composer.model.ConversationDraftState +import com.android.messaging.ui.conversation.v2.messages.mapper.ConversationVCardAttachmentUiModelMapper +import com.android.messaging.ui.conversation.v2.messages.model.attachment.ConversationVCardAttachmentMetadata +import com.android.messaging.ui.conversation.v2.messages.repository.ConversationVCardMetadataRepository +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.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.launch + +internal interface ConversationComposerAttachmentsDelegate { + val state: StateFlow> + + fun bind( + scope: CoroutineScope, + draftStateFlow: StateFlow, + ) +} + +@OptIn(ExperimentalCoroutinesApi::class) +internal class ConversationComposerAttachmentsDelegateImpl @Inject constructor( + private val conversationComposerAttachmentUiModelMapper: + ConversationComposerAttachmentUiModelMapper, + private val conversationVCardAttachmentUiModelMapper: ConversationVCardAttachmentUiModelMapper, + private val conversationVCardMetadataRepository: ConversationVCardMetadataRepository, + @param:DefaultDispatcher + private val defaultDispatcher: CoroutineDispatcher, +) : ConversationComposerAttachmentsDelegate { + + private val _state = MutableStateFlow>( + value = persistentListOf(), + ) + + override val state = _state.asStateFlow() + + private var isBound = false + + override fun bind( + scope: CoroutineScope, + draftStateFlow: StateFlow, + ) { + if (isBound) { + return + } + + isBound = true + _state.value = mapAttachmentUiModels( + attachmentSource = createAttachmentSource( + draftState = draftStateFlow.value, + ), + ) + + scope.launch(defaultDispatcher) { + draftStateFlow + .map(::createAttachmentSource) + .distinctUntilChanged() + .flatMapLatest(::observeAttachmentUiModels) + .collect { attachmentUiModels -> + _state.value = attachmentUiModels + } + } + } + + private fun createAttachmentSource( + draftState: ConversationDraftState, + ): ComposerAttachmentSource { + return ComposerAttachmentSource( + attachments = draftState.draft.attachments, + pendingAttachments = draftState.pendingAttachments, + ) + } + + private fun observeAttachmentUiModels( + attachmentSource: ComposerAttachmentSource, + ): Flow> { + val attachmentUiModels = mapAttachmentUiModels( + attachmentSource = attachmentSource, + ) + val vCardContentUris = attachmentUiModels + .asSequence() + .filterIsInstance() + .map { it.contentUri } + .distinct() + .toList() + + if (vCardContentUris.isEmpty()) { + return flowOf(attachmentUiModels) + } + + val metadataFlows = vCardContentUris.map { contentUri -> + conversationVCardMetadataRepository + .observeAttachmentMetadata(contentUri = contentUri) + .map { metadata -> + contentUri to metadata + } + } + + return combine(flows = metadataFlows) { contentUriAndMetadata -> + val vCardAttachmentMetadata = contentUriAndMetadata.associate { pair -> + pair.first to pair.second + } + + updateAttachmentUiModelsWithVCardUiModel( + attachments = attachmentUiModels, + vCardAttachmentMetadata = vCardAttachmentMetadata, + ) + } + .onStart { + emit(attachmentUiModels) + } + .flowOn(defaultDispatcher) + } + + private fun mapAttachmentUiModels( + attachmentSource: ComposerAttachmentSource, + ): ImmutableList { + return conversationComposerAttachmentUiModelMapper.map( + attachments = attachmentSource.attachments, + pendingAttachments = attachmentSource.pendingAttachments, + ) + } + + private fun updateAttachmentUiModelsWithVCardUiModel( + attachments: ImmutableList, + vCardAttachmentMetadata: Map, + ): ImmutableList { + return attachments + .map { attachment -> + updateAttachmentUiModelWithVCardUiModel( + attachment = attachment, + vCardAttachmentMetadata = vCardAttachmentMetadata, + ) + } + .toImmutableList() + } + + private fun updateAttachmentUiModelWithVCardUiModel( + attachment: ComposerAttachmentUiModel, + vCardAttachmentMetadata: Map, + ): ComposerAttachmentUiModel { + return when (attachment) { + is ComposerAttachmentUiModel.Pending -> { + attachment + } + + is ComposerAttachmentUiModel.Resolved.Audio, + is ComposerAttachmentUiModel.Resolved.File, + is ComposerAttachmentUiModel.Resolved.VisualMedia.Image, + is ComposerAttachmentUiModel.Resolved.VisualMedia.Video, + -> { + attachment + } + + is ComposerAttachmentUiModel.Resolved.VCard -> { + val resolvedVCardMetadata = vCardAttachmentMetadata[attachment.contentUri] + ?: ConversationVCardAttachmentMetadata.Loading + + attachment.copy( + vCardUiModel = conversationVCardAttachmentUiModelMapper.map( + metadata = resolvedVCardMetadata, + ), + ) + } + } + } + + private data class ComposerAttachmentSource( + val attachments: List, + val pendingAttachments: List, + ) +} diff --git a/src/com/android/messaging/ui/conversation/v2/composer/mapper/ConversationComposerAttachmentUiModelMapper.kt b/src/com/android/messaging/ui/conversation/v2/composer/mapper/ConversationComposerAttachmentUiModelMapper.kt new file mode 100644 index 00000000..f32227f2 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/v2/composer/mapper/ConversationComposerAttachmentUiModelMapper.kt @@ -0,0 +1,99 @@ +package com.android.messaging.ui.conversation.v2.composer.mapper + +import com.android.messaging.data.conversation.model.draft.ConversationDraftAttachment +import com.android.messaging.data.conversation.model.draft.ConversationDraftPendingAttachment +import com.android.messaging.ui.conversation.v2.composer.model.ComposerAttachmentUiModel +import com.android.messaging.ui.conversation.v2.messages.mapper.ConversationVCardAttachmentUiModelMapper +import com.android.messaging.ui.conversation.v2.messages.model.attachment.ConversationVCardAttachmentMetadata +import com.android.messaging.util.ContentType +import javax.inject.Inject +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList + +internal interface ConversationComposerAttachmentUiModelMapper { + fun map( + attachments: List, + pendingAttachments: List, + ): ImmutableList +} + +internal class ConversationComposerAttachmentUiModelMapperImpl @Inject constructor( + private val conversationVCardAttachmentUiModelMapper: ConversationVCardAttachmentUiModelMapper, +) : ConversationComposerAttachmentUiModelMapper { + + override fun map( + attachments: List, + pendingAttachments: List, + ): ImmutableList { + val resolvedAttachments = attachments.map { attachment -> + createResolvedAttachmentUiModel( + attachment = attachment, + ) + } + val pendingAttachmentUiModels = pendingAttachments.map { pendingAttachment -> + ComposerAttachmentUiModel.Pending( + key = pendingAttachment.pendingAttachmentId, + contentType = pendingAttachment.contentType, + contentUri = pendingAttachment.contentUri, + displayName = pendingAttachment.displayName, + ) + } + + return (resolvedAttachments + pendingAttachmentUiModels).toImmutableList() + } + + private fun createResolvedAttachmentUiModel( + attachment: ConversationDraftAttachment, + ): ComposerAttachmentUiModel.Resolved { + return when { + ContentType.isAudioType(attachment.contentType) -> { + ComposerAttachmentUiModel.Resolved.Audio( + key = attachment.contentUri, + contentType = attachment.contentType, + contentUri = attachment.contentUri, + ) + } + + ContentType.isImageType(attachment.contentType) -> { + ComposerAttachmentUiModel.Resolved.VisualMedia.Image( + key = attachment.contentUri, + contentType = attachment.contentType, + contentUri = attachment.contentUri, + captionText = attachment.captionText, + width = attachment.width, + height = attachment.height, + ) + } + + ContentType.isVCardType(attachment.contentType) -> { + ComposerAttachmentUiModel.Resolved.VCard( + key = attachment.contentUri, + contentType = attachment.contentType, + contentUri = attachment.contentUri, + vCardUiModel = conversationVCardAttachmentUiModelMapper.map( + metadata = ConversationVCardAttachmentMetadata.Loading, + ), + ) + } + + ContentType.isVideoType(attachment.contentType) -> { + ComposerAttachmentUiModel.Resolved.VisualMedia.Video( + key = attachment.contentUri, + contentType = attachment.contentType, + contentUri = attachment.contentUri, + captionText = attachment.captionText, + width = attachment.width, + height = attachment.height, + ) + } + + else -> { + ComposerAttachmentUiModel.Resolved.File( + key = attachment.contentUri, + contentType = attachment.contentType, + contentUri = attachment.contentUri, + ) + } + } + } +} diff --git a/src/com/android/messaging/ui/conversation/v2/composer/mapper/ConversationComposerUiStateMapper.kt b/src/com/android/messaging/ui/conversation/v2/composer/mapper/ConversationComposerUiStateMapper.kt index a46ecdd8..e5404e3c 100644 --- a/src/com/android/messaging/ui/conversation/v2/composer/mapper/ConversationComposerUiStateMapper.kt +++ b/src/com/android/messaging/ui/conversation/v2/composer/mapper/ConversationComposerUiStateMapper.kt @@ -2,17 +2,17 @@ package com.android.messaging.ui.conversation.v2.composer.mapper import com.android.messaging.data.conversation.model.metadata.ConversationComposerAvailability import com.android.messaging.data.conversation.model.metadata.ConversationSubscription -import com.android.messaging.ui.conversation.v2.composer.model.ConversationComposerAttachmentUiState +import com.android.messaging.ui.conversation.v2.composer.model.ComposerAttachmentUiModel import com.android.messaging.ui.conversation.v2.composer.model.ConversationComposerUiState import com.android.messaging.ui.conversation.v2.composer.model.ConversationDraftState import com.android.messaging.ui.conversation.v2.composer.model.ConversationSimSelectorUiState import javax.inject.Inject import kotlinx.collections.immutable.ImmutableList -import kotlinx.collections.immutable.toImmutableList internal interface ConversationComposerUiStateMapper { fun map( draftState: ConversationDraftState, + attachments: ImmutableList, composerAvailability: ConversationComposerAvailability, subscriptions: ImmutableList, ): ConversationComposerUiState @@ -23,6 +23,7 @@ internal class ConversationComposerUiStateMapperImpl @Inject constructor() : override fun map( draftState: ConversationDraftState, + attachments: ImmutableList, composerAvailability: ConversationComposerAvailability, subscriptions: ImmutableList, ): ConversationComposerUiState { @@ -42,7 +43,7 @@ internal class ConversationComposerUiStateMapperImpl @Inject constructor() : draftState.pendingAttachments.isEmpty() return ConversationComposerUiState( - attachments = draftState.toAttachmentUiState(), + attachments = attachments, messageText = draft.messageText, subjectText = draft.subjectText, selfParticipantId = draft.selfParticipantId, @@ -78,29 +79,4 @@ internal class ConversationComposerUiStateMapperImpl @Inject constructor() : selectedSubscription = selected, ) } - - private fun ConversationDraftState.toAttachmentUiState(): - ImmutableList { - val resolvedAttachments = draft.attachments.map { attachment -> - ConversationComposerAttachmentUiState.Resolved( - key = attachment.contentUri, - contentType = attachment.contentType, - contentUri = attachment.contentUri, - captionText = attachment.captionText, - width = attachment.width, - height = attachment.height, - ) - } - - val pendingAttachments = pendingAttachments.map { pendingAttachment -> - ConversationComposerAttachmentUiState.Pending( - key = pendingAttachment.pendingAttachmentId, - contentType = pendingAttachment.contentType, - contentUri = pendingAttachment.contentUri, - displayName = pendingAttachment.displayName, - ) - } - - return (resolvedAttachments + pendingAttachments).toImmutableList() - } } diff --git a/src/com/android/messaging/ui/conversation/v2/composer/model/ComposerAttachmentUiModel.kt b/src/com/android/messaging/ui/conversation/v2/composer/model/ComposerAttachmentUiModel.kt new file mode 100644 index 00000000..87f7dcae --- /dev/null +++ b/src/com/android/messaging/ui/conversation/v2/composer/model/ComposerAttachmentUiModel.kt @@ -0,0 +1,72 @@ +package com.android.messaging.ui.conversation.v2.composer.model + +import androidx.compose.runtime.Immutable +import com.android.messaging.ui.conversation.v2.messages.model.attachment.ConversationVCardAttachmentUiModel + +@Immutable +internal sealed interface ComposerAttachmentUiModel { + val key: String + val contentType: String + val contentUri: String + + @Immutable + data class Pending( + override val key: String, + override val contentType: String, + override val contentUri: String, + val displayName: String, + ) : ComposerAttachmentUiModel + + @Immutable + sealed interface Resolved : ComposerAttachmentUiModel { + + @Immutable + sealed interface VisualMedia : Resolved { + val captionText: String + val width: Int? + val height: Int? + + @Immutable + data class Image( + override val key: String, + override val contentType: String, + override val contentUri: String, + override val captionText: String, + override val width: Int?, + override val height: Int?, + ) : VisualMedia + + @Immutable + data class Video( + override val key: String, + override val contentType: String, + override val contentUri: String, + override val captionText: String, + override val width: Int?, + override val height: Int?, + ) : VisualMedia + } + + @Immutable + data class Audio( + override val key: String, + override val contentType: String, + override val contentUri: String, + ) : Resolved + + @Immutable + data class File( + override val key: String, + override val contentType: String, + override val contentUri: String, + ) : Resolved + + @Immutable + data class VCard( + override val key: String, + override val contentType: String, + override val contentUri: String, + val vCardUiModel: ConversationVCardAttachmentUiModel, + ) : Resolved + } +} diff --git a/src/com/android/messaging/ui/conversation/v2/composer/model/ConversationComposerAttachmentUiState.kt b/src/com/android/messaging/ui/conversation/v2/composer/model/ConversationComposerAttachmentUiState.kt deleted file mode 100644 index 92ecd045..00000000 --- a/src/com/android/messaging/ui/conversation/v2/composer/model/ConversationComposerAttachmentUiState.kt +++ /dev/null @@ -1,28 +0,0 @@ -package com.android.messaging.ui.conversation.v2.composer.model - -import androidx.compose.runtime.Immutable - -@Immutable -internal sealed interface ConversationComposerAttachmentUiState { - val key: String - val contentType: String - val contentUri: String - - @Immutable - data class Pending( - override val key: String, - override val contentType: String, - override val contentUri: String, - val displayName: String, - ) : ConversationComposerAttachmentUiState - - @Immutable - data class Resolved( - override val key: String, - override val contentType: String, - override val contentUri: String, - val captionText: String, - val width: Int?, - val height: Int?, - ) : ConversationComposerAttachmentUiState -} diff --git a/src/com/android/messaging/ui/conversation/v2/composer/model/ConversationComposerUiState.kt b/src/com/android/messaging/ui/conversation/v2/composer/model/ConversationComposerUiState.kt index 61c5fc53..3af7d00d 100644 --- a/src/com/android/messaging/ui/conversation/v2/composer/model/ConversationComposerUiState.kt +++ b/src/com/android/messaging/ui/conversation/v2/composer/model/ConversationComposerUiState.kt @@ -7,7 +7,7 @@ import kotlinx.collections.immutable.persistentListOf @Immutable internal data class ConversationComposerUiState( - val attachments: ImmutableList = persistentListOf(), + val attachments: ImmutableList = persistentListOf(), val messageText: String = "", val subjectText: String = "", val selfParticipantId: String = "", diff --git a/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationAttachmentPreview.kt b/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationAttachmentPreview.kt index 8960bbc7..cc2ffbfa 100644 --- a/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationAttachmentPreview.kt +++ b/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationAttachmentPreview.kt @@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyRow @@ -32,21 +33,25 @@ import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import com.android.messaging.R import com.android.messaging.ui.conversation.v2.CONVERSATION_ATTACHMENT_PREVIEW_LIST_TEST_TAG -import com.android.messaging.ui.conversation.v2.composer.model.ConversationComposerAttachmentUiState +import com.android.messaging.ui.conversation.v2.composer.model.ComposerAttachmentUiModel import com.android.messaging.ui.conversation.v2.conversationAttachmentPreviewItemTestTag import com.android.messaging.ui.conversation.v2.conversationAttachmentPreviewRemoveButtonTestTag import com.android.messaging.ui.conversation.v2.mediapicker.component.ConversationMediaThumbnail -import com.android.messaging.util.ContentType +import com.android.messaging.ui.conversation.v2.messages.model.attachment.ConversationVCardAttachmentUiModel +import com.android.messaging.ui.conversation.v2.messages.ui.attachment.ConversationVCardAttachmentCardContent +import kotlinx.collections.immutable.ImmutableList private val ATTACHMENT_PREVIEW_CORNER_RADIUS = 20.dp +private val ATTACHMENT_PREVIEW_CARD_HEIGHT = 88.dp +private val ATTACHMENT_PREVIEW_CARD_WIDTH = 220.dp private const val ATTACHMENT_PREVIEW_SIZE_PX = 256 @Composable internal fun ConversationAttachmentPreview( modifier: Modifier = Modifier, - attachments: List, + attachments: ImmutableList, onPendingAttachmentRemove: (String) -> Unit, - onResolvedAttachmentClick: (ConversationComposerAttachmentUiState.Resolved) -> Unit, + onResolvedAttachmentClick: (ComposerAttachmentUiModel.Resolved) -> Unit, onResolvedAttachmentRemove: (String) -> Unit, ) { if (attachments.isEmpty()) { @@ -68,7 +73,7 @@ internal fun ConversationAttachmentPreview( key = { attachment -> attachment.key }, ) { attachment -> when (attachment) { - is ConversationComposerAttachmentUiState.Pending -> { + is ComposerAttachmentUiModel.Pending -> { PendingAttachmentPreviewItem( attachmentKey = attachment.key, onRemoveClick = { @@ -77,7 +82,7 @@ internal fun ConversationAttachmentPreview( ) } - is ConversationComposerAttachmentUiState.Resolved -> { + is ComposerAttachmentUiModel.Resolved -> { ResolvedAttachmentPreviewItem( attachment = attachment, attachmentKey = attachment.key, @@ -100,6 +105,7 @@ private fun PendingAttachmentPreviewItem( onRemoveClick: () -> Unit, ) { AttachmentPreviewItemContainer( + modifier = Modifier.size(88.dp), attachmentKey = attachmentKey, onClick = {}, ) { @@ -130,7 +136,39 @@ private fun PendingAttachmentPreviewItem( @Composable private fun ResolvedAttachmentPreviewItem( - attachment: ConversationComposerAttachmentUiState.Resolved, + attachment: ComposerAttachmentUiModel.Resolved, + attachmentKey: String, + onAttachmentClick: () -> Unit, + onRemoveClick: () -> Unit, +) { + when (attachment) { + is ComposerAttachmentUiModel.Resolved.VCard -> { + ConversationVCardAttachmentPreviewItem( + attachmentKey = attachmentKey, + uiModel = attachment.vCardUiModel, + onAttachmentClick = onAttachmentClick, + onRemoveClick = onRemoveClick, + ) + } + + is ComposerAttachmentUiModel.Resolved.Audio, + is ComposerAttachmentUiModel.Resolved.File, + is ComposerAttachmentUiModel.Resolved.VisualMedia.Image, + is ComposerAttachmentUiModel.Resolved.VisualMedia.Video, + -> { + ConversationResolvedAttachmentThumbnailPreviewItem( + attachment = attachment, + attachmentKey = attachmentKey, + onAttachmentClick = onAttachmentClick, + onRemoveClick = onRemoveClick, + ) + } + } +} + +@Composable +private fun ConversationResolvedAttachmentThumbnailPreviewItem( + attachment: ComposerAttachmentUiModel.Resolved, attachmentKey: String, onAttachmentClick: () -> Unit, onRemoveClick: () -> Unit, @@ -141,6 +179,7 @@ private fun ResolvedAttachmentPreviewItem( ) AttachmentPreviewItemContainer( + modifier = Modifier.size(90.dp), attachmentKey = attachmentKey, onClick = onAttachmentClick, ) { @@ -151,18 +190,8 @@ private fun ResolvedAttachmentPreviewItem( size = thumbnailSize, ) - if (ContentType.isVideoType(attachment.contentType)) { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center, - ) { - Icon( - imageVector = Icons.Rounded.PlayArrow, - contentDescription = null, - modifier = Modifier.size(28.dp), - tint = MaterialTheme.colorScheme.onPrimary, - ) - } + if (attachment is ComposerAttachmentUiModel.Resolved.VisualMedia.Video) { + VideoAttachmentOverlay() } RemoveAttachmentButton( @@ -172,15 +201,70 @@ private fun ResolvedAttachmentPreviewItem( } } +@Composable +private fun VideoAttachmentOverlay() { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Rounded.PlayArrow, + contentDescription = null, + modifier = Modifier.size(28.dp), + tint = MaterialTheme.colorScheme.onPrimary, + ) + } +} + +@Composable +private fun ConversationVCardAttachmentPreviewItem( + attachmentKey: String, + uiModel: ConversationVCardAttachmentUiModel, + onAttachmentClick: () -> Unit, + onRemoveClick: () -> Unit, +) { + AttachmentPreviewItemContainer( + modifier = Modifier.size( + width = ATTACHMENT_PREVIEW_CARD_WIDTH, + height = ATTACHMENT_PREVIEW_CARD_HEIGHT, + ), + attachmentKey = attachmentKey, + onClick = onAttachmentClick, + ) { + Box( + modifier = Modifier + .fillMaxSize() + .background(color = MaterialTheme.colorScheme.surfaceContainerHigh), + ) { + ConversationVCardAttachmentCardContent( + modifier = Modifier + .fillMaxWidth() + .align(alignment = Alignment.CenterStart) + .padding(horizontal = 16.dp, vertical = 12.dp), + type = uiModel.type, + titleText = uiModel.titleText, + titleTextResId = uiModel.titleTextResId, + subtitleText = uiModel.subtitleText, + subtitleTextResId = uiModel.subtitleTextResId, + ) + + RemoveAttachmentButton( + attachmentKey = attachmentKey, + onClick = onRemoveClick, + ) + } + } +} + @Composable private fun AttachmentPreviewItemContainer( + modifier: Modifier = Modifier, attachmentKey: String, onClick: () -> Unit, content: @Composable BoxScope.() -> Unit, ) { Surface( - modifier = Modifier - .size(88.dp) + modifier = modifier .clip(RoundedCornerShape(ATTACHMENT_PREVIEW_CORNER_RADIUS)) .clickable(onClick = onClick) .testTag( diff --git a/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationComposeBar.kt b/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationComposeBar.kt index 6df454c2..3f2fae3b 100644 --- a/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationComposeBar.kt +++ b/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationComposeBar.kt @@ -11,7 +11,11 @@ import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.AddCircleOutline import androidx.compose.material.icons.rounded.Image +import androidx.compose.material.icons.rounded.Person +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -20,7 +24,11 @@ import androidx.compose.material3.TextField import androidx.compose.material3.TextFieldColors import androidx.compose.material3.TextFieldDefaults import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester @@ -31,9 +39,12 @@ import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.dp import com.android.messaging.R import com.android.messaging.ui.conversation.v2.CONVERSATION_ATTACHMENT_BUTTON_TEST_TAG +import com.android.messaging.ui.conversation.v2.CONVERSATION_ATTACHMENT_CONTACT_MENU_ITEM_TEST_TAG +import com.android.messaging.ui.conversation.v2.CONVERSATION_ATTACHMENT_MEDIA_MENU_ITEM_TEST_TAG import com.android.messaging.ui.conversation.v2.CONVERSATION_COMPOSE_BAR_TEST_TAG import com.android.messaging.ui.conversation.v2.CONVERSATION_SEND_BUTTON_SHAPE_CIRCLE import com.android.messaging.ui.conversation.v2.CONVERSATION_SEND_BUTTON_TEST_TAG @@ -49,7 +60,8 @@ internal fun ConversationComposeBar( isAttachmentActionEnabled: Boolean, isSendActionEnabled: Boolean, messageFieldFocusRequester: FocusRequester? = null, - onAttachmentClick: () -> Unit, + onContactAttachClick: () -> Unit, + onMediaPickerClick: () -> Unit, onMessageTextChange: (String) -> Unit, onSendClick: () -> Unit, ) { @@ -69,7 +81,8 @@ internal fun ConversationComposeBar( isSendActionEnabled = isSendActionEnabled, messageFieldFocusRequester = messageFieldFocusRequester, presentation = presentation, - onAttachmentClick = onAttachmentClick, + onContactAttachClick = onContactAttachClick, + onMediaPickerClick = onMediaPickerClick, onMessageTextChange = onMessageTextChange, onSendClick = onSendClick, ) @@ -120,7 +133,8 @@ private fun ConversationComposeTextField( isSendActionEnabled: Boolean, messageFieldFocusRequester: FocusRequester?, presentation: ConversationComposeBarPresentation, - onAttachmentClick: () -> Unit, + onContactAttachClick: () -> Unit, + onMediaPickerClick: () -> Unit, onMessageTextChange: (String) -> Unit, onSendClick: () -> Unit, ) { @@ -155,11 +169,12 @@ private fun ConversationComposeTextField( placeholder = { ConversationComposePlaceholder() }, - trailingIcon = { - ConversationComposeImageAction( + leadingIcon = { + ConversationComposeAttachmentMenu( modifier = Modifier.testTag(CONVERSATION_ATTACHMENT_BUTTON_TEST_TAG), enabled = isAttachmentActionEnabled, - onClick = onAttachmentClick, + onContactAttachClick = onContactAttachClick, + onMediaPickerClick = onMediaPickerClick, ) }, minLines = 1, @@ -187,25 +202,79 @@ private fun ConversationComposePlaceholder() { } @Composable -private fun ConversationComposeImageAction( +private fun ConversationComposeAttachmentMenu( modifier: Modifier = Modifier, enabled: Boolean, - onClick: () -> Unit, + onContactAttachClick: () -> Unit, + onMediaPickerClick: () -> Unit, ) { val hapticFeedback = LocalHapticFeedback.current + var isExpanded by rememberSaveable { + mutableStateOf(value = false) + } - IconButton( + Box( modifier = modifier, - onClick = { - hapticFeedback.performHapticFeedback(HapticFeedbackType.ContextClick) - onClick() - }, - enabled = enabled, ) { - Icon( - imageVector = Icons.Rounded.Image, - contentDescription = null, - ) + IconButton( + onClick = { + hapticFeedback.performHapticFeedback(HapticFeedbackType.ContextClick) + isExpanded = true + }, + enabled = enabled, + ) { + Icon( + imageVector = Icons.Rounded.AddCircleOutline, + contentDescription = stringResource( + id = R.string.attachMediaButtonContentDescription + ), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + DropdownMenu( + expanded = isExpanded, + onDismissRequest = { + isExpanded = false + }, + offset = DpOffset( + x = 0.dp, + y = (-8).dp, + ), + ) { + DropdownMenuItem( + modifier = Modifier.testTag(CONVERSATION_ATTACHMENT_MEDIA_MENU_ITEM_TEST_TAG), + text = { + Text(text = stringResource(id = R.string.mediapicker_gallery_title)) + }, + leadingIcon = { + Icon( + imageVector = Icons.Rounded.Image, + contentDescription = null, + ) + }, + onClick = { + isExpanded = false + onMediaPickerClick() + }, + ) + DropdownMenuItem( + modifier = Modifier.testTag(CONVERSATION_ATTACHMENT_CONTACT_MENU_ITEM_TEST_TAG), + text = { + Text(text = stringResource(id = R.string.mediapicker_contact_title)) + }, + leadingIcon = { + Icon( + imageVector = Icons.Rounded.Person, + contentDescription = null, + ) + }, + onClick = { + isExpanded = false + onContactAttachClick() + }, + ) + } } } diff --git a/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationComposerSection.kt b/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationComposerSection.kt index 7f7f0f77..f1e5bc7d 100644 --- a/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationComposerSection.kt +++ b/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationComposerSection.kt @@ -4,21 +4,23 @@ import androidx.compose.foundation.layout.Column import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester -import com.android.messaging.ui.conversation.v2.composer.model.ConversationComposerAttachmentUiState +import com.android.messaging.ui.conversation.v2.composer.model.ComposerAttachmentUiModel +import kotlinx.collections.immutable.ImmutableList @Composable internal fun ConversationComposerSection( modifier: Modifier = Modifier, - attachments: List, + attachments: ImmutableList, messageText: String, isMessageFieldEnabled: Boolean, isAttachmentActionEnabled: Boolean, isSendActionEnabled: Boolean, messageFieldFocusRequester: FocusRequester, - onAttachmentClick: () -> Unit, + onContactAttachClick: () -> Unit, + onMediaPickerClick: () -> Unit, onMessageTextChange: (String) -> Unit, onPendingAttachmentRemove: (String) -> Unit, - onResolvedAttachmentClick: (ConversationComposerAttachmentUiState.Resolved) -> Unit, + onResolvedAttachmentClick: (ComposerAttachmentUiModel.Resolved) -> Unit, onResolvedAttachmentRemove: (String) -> Unit, onSendClick: () -> Unit, ) { @@ -38,7 +40,8 @@ internal fun ConversationComposerSection( isAttachmentActionEnabled = isAttachmentActionEnabled, isSendActionEnabled = isSendActionEnabled, messageFieldFocusRequester = messageFieldFocusRequester, - onAttachmentClick = onAttachmentClick, + onContactAttachClick = onContactAttachClick, + onMediaPickerClick = onMediaPickerClick, onMessageTextChange = onMessageTextChange, onSendClick = onSendClick, ) diff --git a/src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationAttachmentBridge.kt b/src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationAttachmentBridge.kt deleted file mode 100644 index 531dfd30..00000000 --- a/src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationAttachmentBridge.kt +++ /dev/null @@ -1,82 +0,0 @@ -package com.android.messaging.ui.conversation.v2.mediapicker - -import android.content.ContentResolver -import androidx.core.net.toUri -import com.android.messaging.data.conversation.model.draft.ConversationDraftAttachment -import com.android.messaging.data.media.model.ConversationMediaItem -import com.android.messaging.datamodel.MediaScratchFileProvider -import com.android.messaging.di.core.IoDispatcher -import com.android.messaging.ui.conversation.v2.mediapicker.model.ConversationCapturedMedia -import com.android.messaging.util.LogUtil -import com.android.messaging.util.core.extension.unitFlow -import javax.inject.Inject -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.catch -import kotlinx.coroutines.flow.flowOn - -internal interface ConversationAttachmentBridge { - fun createDraftAttachments( - mediaItems: Collection, - ): List - - fun createDraftAttachment( - capturedMedia: ConversationCapturedMedia, - ): ConversationDraftAttachment - - fun deleteTemporaryAttachment( - contentUri: String, - ): Flow -} - -internal class ConversationAttachmentBridgeImpl @Inject constructor( - private val contentResolver: ContentResolver, - @param:IoDispatcher - private val ioDispatcher: CoroutineDispatcher, -) : ConversationAttachmentBridge { - - override fun createDraftAttachments( - mediaItems: Collection, - ): List { - return mediaItems.map { mediaItem -> - ConversationDraftAttachment( - contentType = mediaItem.contentType, - contentUri = mediaItem.contentUri, - width = mediaItem.width, - height = mediaItem.height, - ) - } - } - - override fun createDraftAttachment( - capturedMedia: ConversationCapturedMedia, - ): ConversationDraftAttachment { - return ConversationDraftAttachment( - contentType = capturedMedia.contentType, - contentUri = capturedMedia.contentUri, - width = capturedMedia.width, - height = capturedMedia.height, - ) - } - - override fun deleteTemporaryAttachment(contentUri: String): Flow { - return unitFlow { - val attachmentUri = contentUri.toUri() - if (MediaScratchFileProvider.isMediaScratchSpaceUri(attachmentUri)) { - contentResolver.delete(attachmentUri, null, null) - } - }.catch { throwable -> - if (throwable is CancellationException) { - throw throwable - } - - LogUtil.w(TAG, "Failed to delete temporary attachment $contentUri", throwable) - emit(Unit) - }.flowOn(ioDispatcher) - } - - private companion object { - private const val TAG = "ConversationAttachmentBridge" - } -} diff --git a/src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationMediaPicker.kt b/src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationMediaPicker.kt index 68cabfde..32586b7c 100644 --- a/src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationMediaPicker.kt +++ b/src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationMediaPicker.kt @@ -12,7 +12,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.lifecycle.compose.LocalLifecycleOwner import com.android.messaging.data.media.model.ConversationMediaItem -import com.android.messaging.ui.conversation.v2.composer.model.ConversationComposerAttachmentUiState +import com.android.messaging.ui.conversation.v2.composer.model.ComposerAttachmentUiModel import com.android.messaging.ui.conversation.v2.mediapicker.camera.BindConversationCameraLifecycleEffect import com.android.messaging.ui.conversation.v2.mediapicker.camera.rememberConversationCameraController import com.android.messaging.ui.conversation.v2.mediapicker.model.ConversationCapturedMedia @@ -25,7 +25,7 @@ import kotlinx.collections.immutable.toImmutableList internal fun ConversationMediaPicker( modifier: Modifier = Modifier, uiState: ConversationMediaPickerUiState, - attachments: ImmutableList, + attachments: ImmutableList, conversationTitle: String?, isSendActionEnabled: Boolean, state: ConversationMediaPickerState, @@ -33,7 +33,7 @@ internal fun ConversationMediaPicker( audioPermissionGranted: Boolean, galleryPermissionGranted: Boolean, onClose: () -> Unit, - onAttachmentPreviewClick: (ConversationComposerAttachmentUiState.Resolved) -> Unit, + onAttachmentPreviewClick: (ComposerAttachmentUiModel.Resolved.VisualMedia) -> Unit, onAttachmentCaptionChange: (String, String) -> Unit, onAttachmentRemove: (String) -> Unit, onGalleryMediaConfirmed: (List) -> Unit, @@ -46,14 +46,14 @@ internal fun ConversationMediaPicker( val cameraController = rememberConversationCameraController() val lifecycleOwner = LocalLifecycleOwner.current - val resolvedAttachments = remember(attachments) { + val visualAttachments = remember(attachments) { attachments .asSequence() - .filterIsInstance() + .filterIsInstance() .toImmutableList() } - val isReviewVisible = state.isReviewRequested && resolvedAttachments.isNotEmpty() + val isReviewVisible = state.isReviewRequested && visualAttachments.isNotEmpty() val sheetState = rememberStandardBottomSheetState( initialValue = SheetValue.PartiallyExpanded, skipHiddenState = true, @@ -73,7 +73,6 @@ internal fun ConversationMediaPicker( onGalleryMediaConfirmed = onGalleryMediaConfirmed, onShowReview = state::showReview, onSelectionHandled = { - @Suppress("AssignedValueIsNeverRead") pendingSelectedMediaItem = null }, ) @@ -89,7 +88,7 @@ internal fun ConversationMediaPicker( cameraController = cameraController, scaffoldState = scaffoldState, uiState = uiState, - resolvedAttachments = resolvedAttachments, + visualAttachments = visualAttachments, conversationTitle = conversationTitle, captureMode = state.captureMode, reviewContentUri = state.reviewContentUri, @@ -104,7 +103,6 @@ internal fun ConversationMediaPicker( onAttachmentCaptionChange = onAttachmentCaptionChange, onAttachmentRemove = onAttachmentRemove, onGalleryMediaClick = { mediaItem -> - @Suppress("AssignedValueIsNeverRead") pendingSelectedMediaItem = mediaItem }, onRequestAudioPermission = onRequestAudioPermission, diff --git a/src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationMediaPickerDelegate.kt b/src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationMediaPickerDelegate.kt index c0fa5f7e..817dd3c8 100644 --- a/src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationMediaPickerDelegate.kt +++ b/src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationMediaPickerDelegate.kt @@ -5,8 +5,10 @@ import com.android.messaging.data.media.repository.ConversationMediaRepository import com.android.messaging.di.core.DefaultDispatcher import com.android.messaging.ui.conversation.v2.common.ConversationScreenDelegate import com.android.messaging.ui.conversation.v2.composer.delegate.ConversationDraftDelegate +import com.android.messaging.ui.conversation.v2.mediapicker.mapper.ConversationDraftAttachmentMapper import com.android.messaging.ui.conversation.v2.mediapicker.model.ConversationCapturedMedia import com.android.messaging.ui.conversation.v2.mediapicker.model.ConversationMediaPickerUiState +import com.android.messaging.ui.conversation.v2.mediapicker.repository.ConversationAttachmentRepository import com.android.messaging.ui.conversation.v2.screen.model.ConversationScreenEffect import com.android.messaging.util.LogUtil import javax.inject.Inject @@ -22,6 +24,7 @@ import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @@ -36,6 +39,8 @@ internal interface ConversationMediaPickerDelegate : fun onCapturedMediaReady(capturedMedia: ConversationCapturedMedia) + fun onContactCardPicked(contactUri: String?) + fun onRemovePendingAttachment(pendingAttachmentId: String) fun onRemoveResolvedAttachment(contentUri: String) @@ -45,7 +50,8 @@ internal interface ConversationMediaPickerDelegate : internal class ConversationMediaPickerDelegateImpl @Inject constructor( private val conversationDraftDelegate: ConversationDraftDelegate, - private val conversationAttachmentBridge: ConversationAttachmentBridge, + private val conversationAttachmentRepository: ConversationAttachmentRepository, + private val conversationDraftAttachmentMapper: ConversationDraftAttachmentMapper, private val conversationMediaRepository: ConversationMediaRepository, @param:DefaultDispatcher private val defaultDispatcher: CoroutineDispatcher, @@ -86,9 +92,11 @@ internal class ConversationMediaPickerDelegateImpl @Inject constructor( } conversationDraftDelegate.addAttachments( - attachments = conversationAttachmentBridge.createDraftAttachments( - mediaItems = mediaItems, - ), + attachments = mediaItems.map { mediaItem -> + conversationDraftAttachmentMapper.map( + mediaItem = mediaItem, + ) + }, ) } @@ -132,13 +140,25 @@ internal class ConversationMediaPickerDelegateImpl @Inject constructor( override fun onCapturedMediaReady(capturedMedia: ConversationCapturedMedia) { conversationDraftDelegate.addAttachments( attachments = listOf( - conversationAttachmentBridge.createDraftAttachment( + conversationDraftAttachmentMapper.map( capturedMedia = capturedMedia, ), ), ) } + override fun onContactCardPicked(contactUri: String?) { + val resolvedContactUri = contactUri?.takeIf { it.isNotBlank() } ?: return + + boundScope?.launch(defaultDispatcher) { + conversationAttachmentRepository + .createDraftAttachmentFromContact(contactUri = resolvedContactUri) + .filterNotNull() + .map(::listOf) + .collect(conversationDraftDelegate::addAttachments) + } + } + override fun onRemovePendingAttachment(pendingAttachmentId: String) { pendingAttachmentJobs.remove(pendingAttachmentId)?.cancel() conversationDraftDelegate.removePendingAttachment( @@ -150,7 +170,7 @@ internal class ConversationMediaPickerDelegateImpl @Inject constructor( conversationDraftDelegate.removeAttachment(contentUri = contentUri) boundScope?.launch(defaultDispatcher) { - conversationAttachmentBridge + conversationAttachmentRepository .deleteTemporaryAttachment(contentUri = contentUri) .collect() } diff --git a/src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationMediaPickerOverlay.kt b/src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationMediaPickerOverlay.kt index faf7dd25..9a3373e6 100644 --- a/src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationMediaPickerOverlay.kt +++ b/src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationMediaPickerOverlay.kt @@ -17,7 +17,7 @@ import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.testTag import com.android.messaging.data.media.model.ConversationMediaItem import com.android.messaging.ui.conversation.v2.CONVERSATION_MEDIA_PICKER_OVERLAY_TEST_TAG -import com.android.messaging.ui.conversation.v2.composer.model.ConversationComposerAttachmentUiState +import com.android.messaging.ui.conversation.v2.composer.model.ComposerAttachmentUiModel import com.android.messaging.ui.conversation.v2.mediapicker.model.ConversationCapturedMedia import com.android.messaging.ui.conversation.v2.mediapicker.model.ConversationMediaPickerUiState import kotlinx.collections.immutable.ImmutableList @@ -28,11 +28,11 @@ internal fun ConversationMediaPickerOverlay( modifier: Modifier = Modifier, state: ConversationMediaPickerState, mediaPickerUiState: ConversationMediaPickerUiState, - attachments: ImmutableList, + attachments: ImmutableList, conversationTitle: String?, isSendActionEnabled: Boolean, messageFieldFocusRequester: FocusRequester, - onAttachmentPreviewClick: (ConversationComposerAttachmentUiState.Resolved) -> Unit, + onAttachmentPreviewClick: (ComposerAttachmentUiModel.Resolved.VisualMedia) -> Unit, onAttachmentCaptionChange: (String, String) -> Unit, onAttachmentRemove: (String) -> Unit, onGalleryMediaConfirmed: (List) -> Unit, diff --git a/src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationMediaPickerScaffold.kt b/src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationMediaPickerScaffold.kt index c7b1f42b..c989ffa3 100644 --- a/src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationMediaPickerScaffold.kt +++ b/src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationMediaPickerScaffold.kt @@ -26,7 +26,7 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.android.messaging.data.media.model.ConversationMediaItem -import com.android.messaging.ui.conversation.v2.composer.model.ConversationComposerAttachmentUiState +import com.android.messaging.ui.conversation.v2.composer.model.ComposerAttachmentUiModel import com.android.messaging.ui.conversation.v2.mediapicker.camera.ConversationCameraController import com.android.messaging.ui.conversation.v2.mediapicker.component.capture.ConversationMediaCameraPreviewSurface import com.android.messaging.ui.conversation.v2.mediapicker.component.gallery.ConversationGallerySheet @@ -50,7 +50,7 @@ internal fun ConversationMediaPickerScaffold( cameraController: ConversationCameraController, scaffoldState: BottomSheetScaffoldState, uiState: ConversationMediaPickerUiState, - resolvedAttachments: ImmutableList, + visualAttachments: ImmutableList, conversationTitle: String?, captureMode: ConversationCaptureMode, reviewContentUri: String?, @@ -61,7 +61,7 @@ internal fun ConversationMediaPickerScaffold( audioPermissionGranted: Boolean, galleryPermissionGranted: Boolean, onClose: () -> Unit, - onAttachmentPreviewClick: (ConversationComposerAttachmentUiState.Resolved) -> Unit, + onAttachmentPreviewClick: (ComposerAttachmentUiModel.Resolved.VisualMedia) -> Unit, onAttachmentCaptionChange: (String, String) -> Unit, onAttachmentRemove: (String) -> Unit, onGalleryMediaClick: (ConversationMediaItem) -> Unit, @@ -133,7 +133,7 @@ internal fun ConversationMediaPickerScaffold( onGalleryMediaClick = onGalleryMediaClick, onRequestGalleryPermission = onRequestGalleryPermission, sheetPeekHeight = sheetPeekHeight, - attachments = resolvedAttachments, + attachments = visualAttachments, conversationTitle = conversationTitle, initiallyReviewedContentUri = reviewContentUri, reviewRequestSequence = reviewRequestSequence, @@ -164,12 +164,12 @@ private fun ConversationMediaPickerReviewScene( onGalleryMediaClick: (ConversationMediaItem) -> Unit, onRequestGalleryPermission: () -> Unit, sheetPeekHeight: Dp, - attachments: ImmutableList, + attachments: ImmutableList, conversationTitle: String?, initiallyReviewedContentUri: String?, reviewRequestSequence: Int, isSendActionEnabled: Boolean, - onAttachmentPreviewClick: (ConversationComposerAttachmentUiState.Resolved) -> Unit, + onAttachmentPreviewClick: (ComposerAttachmentUiModel.Resolved.VisualMedia) -> Unit, onCaptionChange: (String, String) -> Unit, onAttachmentRemove: (String) -> Unit, onAddMoreClick: () -> Unit, diff --git a/src/com/android/messaging/ui/conversation/v2/mediapicker/component/review/ConversationMediaPickerReview.kt b/src/com/android/messaging/ui/conversation/v2/mediapicker/component/review/ConversationMediaPickerReview.kt index 0a316f01..49aa4de9 100644 --- a/src/com/android/messaging/ui/conversation/v2/mediapicker/component/review/ConversationMediaPickerReview.kt +++ b/src/com/android/messaging/ui/conversation/v2/mediapicker/component/review/ConversationMediaPickerReview.kt @@ -39,7 +39,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import com.android.messaging.R -import com.android.messaging.ui.conversation.v2.composer.model.ConversationComposerAttachmentUiState +import com.android.messaging.ui.conversation.v2.composer.model.ComposerAttachmentUiModel import com.android.messaging.ui.conversation.v2.composer.ui.ConversationSendActionButton import com.android.messaging.ui.conversation.v2.mediapicker.component.PickerOverlayIconButton import kotlinx.collections.immutable.ImmutableList @@ -52,12 +52,12 @@ private const val PICKER_REVIEW_PAGE_WIDTH_FRACTION = 0.8f internal fun ConversationMediaReviewScene( modifier: Modifier = Modifier, contentPadding: PaddingValues = PaddingValues(), - attachments: ImmutableList, + attachments: ImmutableList, conversationTitle: String?, initiallyReviewedContentUri: String?, reviewRequestSequence: Int, isSendActionEnabled: Boolean, - onAttachmentPreviewClick: (ConversationComposerAttachmentUiState.Resolved) -> Unit, + onAttachmentPreviewClick: (ComposerAttachmentUiModel.Resolved.VisualMedia) -> Unit, onCaptionChange: (String, String) -> Unit, onAttachmentRemove: (String) -> Unit, onAddMoreClick: () -> Unit, @@ -174,10 +174,10 @@ private fun ConversationMediaReviewTopBar( private fun ConversationMediaReviewPager( modifier: Modifier = Modifier, attachmentContentUris: ImmutableList, - attachments: ImmutableList, + attachments: ImmutableList, pagerState: PagerState, visibleDeleteChipPage: Int?, - onAttachmentPreviewClick: (ConversationComposerAttachmentUiState.Resolved) -> Unit, + onAttachmentPreviewClick: (ComposerAttachmentUiModel.Resolved.VisualMedia) -> Unit, onAttachmentRemove: (String) -> Unit, onClearReview: () -> Unit, ) { @@ -307,10 +307,10 @@ private fun ReviewCaptionTextField( cursorColor = MaterialTheme.colorScheme.primary, focusedPlaceholderColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.8f), unfocusedPlaceholderColor = MaterialTheme.colorScheme.onSurfaceVariant.copy( - alpha = 0.8f + alpha = 0.8f, ), disabledPlaceholderColor = MaterialTheme.colorScheme.onSurfaceVariant.copy( - alpha = 0.5f + alpha = 0.5f, ), ), placeholder = { @@ -324,7 +324,7 @@ private fun ReviewCaptionTextField( @Composable private fun ConversationMediaReviewBottomBar( - attachment: ConversationComposerAttachmentUiState.Resolved, + attachment: ComposerAttachmentUiModel.Resolved.VisualMedia, isSendActionEnabled: Boolean, onCaptionChange: (String, String) -> Unit, onSendClick: () -> Unit, diff --git a/src/com/android/messaging/ui/conversation/v2/mediapicker/component/review/ConversationMediaReviewBackground.kt b/src/com/android/messaging/ui/conversation/v2/mediapicker/component/review/ConversationMediaReviewBackground.kt index 9ac4fb9a..43b9659a 100644 --- a/src/com/android/messaging/ui/conversation/v2/mediapicker/component/review/ConversationMediaReviewBackground.kt +++ b/src/com/android/messaging/ui/conversation/v2/mediapicker/component/review/ConversationMediaReviewBackground.kt @@ -19,7 +19,7 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.IntSize import androidx.core.net.toUri -import com.android.messaging.ui.conversation.v2.composer.model.ConversationComposerAttachmentUiState +import com.android.messaging.ui.conversation.v2.composer.model.ComposerAttachmentUiModel import com.android.messaging.ui.conversation.v2.mediapicker.component.loadConversationMediaThumbnailBitmap import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf @@ -31,7 +31,7 @@ private const val PICKER_REVIEW_BACKGROUND_BITMAP_SIZE_PX = 40 internal fun ConversationMediaReviewBackground( modifier: Modifier = Modifier, pagerState: PagerState, - attachments: ImmutableList, + attachments: ImmutableList, ) { val backgroundState = rememberConversationMediaReviewBackgroundState( pagerState = pagerState, @@ -79,7 +79,7 @@ private fun ConversationMediaReviewBackgroundContent( @Composable private fun rememberConversationMediaReviewBackgroundState( pagerState: PagerState, - attachments: ImmutableList, + attachments: ImmutableList, ): ConversationMediaReviewBackgroundState { val backgroundSelection = remember( attachments, @@ -118,8 +118,8 @@ private fun rememberConversationMediaReviewBackgroundState( @Composable private fun rememberConversationMediaReviewBitmapCache( - attachments: ImmutableList, - attachmentsToPrefetch: ImmutableList, + attachments: ImmutableList, + attachmentsToPrefetch: ImmutableList, ): ConversationMediaReviewBitmapCache { val context = LocalContext.current @@ -163,7 +163,7 @@ private fun rememberConversationMediaReviewBitmapCache( } private fun getConversationMediaReviewBackgroundSelection( - attachments: ImmutableList, + attachments: ImmutableList, settledPage: Int, ): ConversationMediaReviewBackgroundSelection { if (attachments.isEmpty()) { @@ -203,7 +203,7 @@ private fun getConversationMediaReviewBackgroundSelection( @Immutable private data class ConversationMediaReviewBackgroundSelection( - val attachmentsToPrefetch: ImmutableList, + val attachmentsToPrefetch: ImmutableList, ) @Immutable diff --git a/src/com/android/messaging/ui/conversation/v2/mediapicker/component/review/ConversationMediaReviewPageCard.kt b/src/com/android/messaging/ui/conversation/v2/mediapicker/component/review/ConversationMediaReviewPageCard.kt index 13fdd3ba..5ea7ddd3 100644 --- a/src/com/android/messaging/ui/conversation/v2/mediapicker/component/review/ConversationMediaReviewPageCard.kt +++ b/src/com/android/messaging/ui/conversation/v2/mediapicker/component/review/ConversationMediaReviewPageCard.kt @@ -39,10 +39,9 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.util.lerp import androidx.compose.ui.zIndex import com.android.messaging.R -import com.android.messaging.ui.conversation.v2.composer.model.ConversationComposerAttachmentUiState +import com.android.messaging.ui.conversation.v2.composer.model.ComposerAttachmentUiModel import com.android.messaging.ui.conversation.v2.mediapicker.component.ConversationMediaThumbnail import com.android.messaging.ui.conversation.v2.mediapicker.component.PickerOverlayBackgroundButton -import com.android.messaging.util.ContentType import kotlin.math.absoluteValue import kotlinx.collections.immutable.ImmutableList import kotlinx.coroutines.delay @@ -51,15 +50,15 @@ private const val PICKER_REVIEW_PAGE_REMOVE_ANIMATION_DURATION_MILLIS = 160 @Composable internal fun ConversationMediaReviewPageCard( - attachment: ConversationComposerAttachmentUiState.Resolved, - attachments: ImmutableList, + attachment: ComposerAttachmentUiModel.Resolved.VisualMedia, + attachments: ImmutableList, page: Int, pageHeight: Dp, pageWidth: Dp, pagerState: PagerState, previewSize: IntSize, shouldShowDeleteChip: Boolean, - onAttachmentPreviewClick: (ConversationComposerAttachmentUiState.Resolved) -> Unit, + onAttachmentPreviewClick: (ComposerAttachmentUiModel.Resolved.VisualMedia) -> Unit, onAttachmentRemove: (String) -> Unit, onClearReview: () -> Unit, ) { @@ -86,8 +85,8 @@ internal fun ConversationMediaReviewPageCard( @Composable private fun rememberConversationMediaReviewPageCardState( - attachment: ConversationComposerAttachmentUiState.Resolved, - attachments: ImmutableList, + attachment: ComposerAttachmentUiModel.Resolved.VisualMedia, + attachments: ImmutableList, shouldShowDeleteChip: Boolean, onAttachmentRemove: (String) -> Unit, onClearReview: () -> Unit, @@ -146,14 +145,14 @@ private fun rememberConversationMediaReviewPageCardState( @Composable private fun ConversationMediaReviewPageCardContent( - attachment: ConversationComposerAttachmentUiState.Resolved, + attachment: ComposerAttachmentUiModel.Resolved.VisualMedia, page: Int, pageHeight: Dp, pageWidth: Dp, pagerState: PagerState, previewSize: IntSize, contentState: ConversationMediaReviewPageCardContentState, - onAttachmentPreviewClick: (ConversationComposerAttachmentUiState.Resolved) -> Unit, + onAttachmentPreviewClick: (ComposerAttachmentUiModel.Resolved.VisualMedia) -> Unit, onAttachmentRemoveClick: () -> Unit, ) { val pageCardModifier = Modifier @@ -214,7 +213,7 @@ private fun ConversationMediaReviewPageCardContent( @Composable private fun ConversationMediaReviewPreview( - attachment: ConversationComposerAttachmentUiState.Resolved, + attachment: ComposerAttachmentUiModel.Resolved.VisualMedia, modifier: Modifier = Modifier, previewSize: IntSize, ) { @@ -244,7 +243,7 @@ private fun ConversationMediaReviewPreview( backgroundColor = Color.Transparent, ) - if (ContentType.isVideoType(attachment.contentType)) { + if (attachment is ComposerAttachmentUiModel.Resolved.VisualMedia.Video) { ConversationMediaReviewVideoBadge() } } diff --git a/src/com/android/messaging/ui/conversation/v2/mediapicker/component/review/ConversationMediaReviewPagerState.kt b/src/com/android/messaging/ui/conversation/v2/mediapicker/component/review/ConversationMediaReviewPagerState.kt index 32760b5d..43e13e02 100644 --- a/src/com/android/messaging/ui/conversation/v2/mediapicker/component/review/ConversationMediaReviewPagerState.kt +++ b/src/com/android/messaging/ui/conversation/v2/mediapicker/component/review/ConversationMediaReviewPagerState.kt @@ -6,20 +6,20 @@ import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember -import com.android.messaging.ui.conversation.v2.composer.model.ConversationComposerAttachmentUiState +import com.android.messaging.ui.conversation.v2.composer.model.ComposerAttachmentUiModel import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList internal data class ConversationMediaReviewPagerState( val attachmentContentUris: ImmutableList, - val currentAttachment: ConversationComposerAttachmentUiState.Resolved, + val currentAttachment: ComposerAttachmentUiModel.Resolved.VisualMedia, val pagerState: PagerState, val visibleDeleteChipPage: Int?, ) @Composable internal fun rememberConversationMediaReviewPagerState( - attachments: ImmutableList, + attachments: ImmutableList, initiallyReviewedContentUri: String?, reviewRequestSequence: Int, ): ConversationMediaReviewPagerState { @@ -85,7 +85,7 @@ private class ConversationMediaReviewPagerCoordinator( suspend fun syncTargetPage( attachmentContentUris: List, - attachments: List, + attachments: List, initiallyReviewedContentUri: String?, reviewRequestSequence: Int, pagerState: PagerState, @@ -136,7 +136,7 @@ private class ConversationMediaReviewPagerCoordinator( } private fun resolveInitialReviewPage( - attachments: List, + attachments: List, initiallyReviewedContentUri: String?, ): Int { return attachments @@ -147,7 +147,7 @@ private fun resolveInitialReviewPage( private fun clampAttachmentPage( page: Int, - attachments: List, + attachments: List, ): Int { return page.coerceIn( minimumValue = 0, @@ -156,7 +156,7 @@ private fun clampAttachmentPage( } private fun resolveVisibleDeleteChipPage( - attachments: List, + attachments: List, pagerState: PagerState, ): Int? { val clampedCurrentPage = clampAttachmentPage( diff --git a/src/com/android/messaging/ui/conversation/v2/mediapicker/mapper/ConversationDraftAttachmentMapper.kt b/src/com/android/messaging/ui/conversation/v2/mediapicker/mapper/ConversationDraftAttachmentMapper.kt new file mode 100644 index 00000000..fedf4938 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/v2/mediapicker/mapper/ConversationDraftAttachmentMapper.kt @@ -0,0 +1,34 @@ +package com.android.messaging.ui.conversation.v2.mediapicker.mapper + +import com.android.messaging.data.conversation.model.draft.ConversationDraftAttachment +import com.android.messaging.data.media.model.ConversationMediaItem +import com.android.messaging.ui.conversation.v2.mediapicker.model.ConversationCapturedMedia +import javax.inject.Inject + +internal interface ConversationDraftAttachmentMapper { + fun map(mediaItem: ConversationMediaItem): ConversationDraftAttachment + + fun map(capturedMedia: ConversationCapturedMedia): ConversationDraftAttachment +} + +internal class ConversationDraftAttachmentMapperImpl @Inject constructor() : + ConversationDraftAttachmentMapper { + + override fun map(mediaItem: ConversationMediaItem): ConversationDraftAttachment { + return ConversationDraftAttachment( + contentType = mediaItem.contentType, + contentUri = mediaItem.contentUri, + width = mediaItem.width, + height = mediaItem.height, + ) + } + + override fun map(capturedMedia: ConversationCapturedMedia): ConversationDraftAttachment { + return ConversationDraftAttachment( + contentType = capturedMedia.contentType, + contentUri = capturedMedia.contentUri, + width = capturedMedia.width, + height = capturedMedia.height, + ) + } +} diff --git a/src/com/android/messaging/ui/conversation/v2/mediapicker/repository/ConversationAttachmentRepository.kt b/src/com/android/messaging/ui/conversation/v2/mediapicker/repository/ConversationAttachmentRepository.kt new file mode 100644 index 00000000..ab47d047 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/v2/mediapicker/repository/ConversationAttachmentRepository.kt @@ -0,0 +1,111 @@ +package com.android.messaging.ui.conversation.v2.mediapicker.repository + +import android.content.ContentResolver +import android.net.Uri +import android.provider.ContactsContract.Contacts +import androidx.core.database.getStringOrNull +import androidx.core.net.toUri +import com.android.messaging.data.conversation.model.draft.ConversationDraftAttachment +import com.android.messaging.datamodel.MediaScratchFileProvider +import com.android.messaging.di.core.IoDispatcher +import com.android.messaging.util.ContentType +import com.android.messaging.util.LogUtil +import com.android.messaging.util.core.extension.typedFlow +import com.android.messaging.util.core.extension.unitFlow +import com.android.messaging.util.db.ext.getStringOrNull +import javax.inject.Inject +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.flowOn + +internal interface ConversationAttachmentRepository { + fun createDraftAttachmentFromContact( + contactUri: String, + ): Flow + + fun deleteTemporaryAttachment( + contentUri: String, + ): Flow +} + +internal class ConversationAttachmentRepositoryImpl @Inject constructor( + private val contentResolver: ContentResolver, + @param:IoDispatcher + private val ioDispatcher: CoroutineDispatcher, +) : ConversationAttachmentRepository { + + override fun createDraftAttachmentFromContact( + contactUri: String, + ): Flow { + return typedFlow { + queryDraftAttachmentFromContact(contactUri = contactUri) + }.catch { throwable -> + if (throwable is CancellationException) { + throw throwable + } + + LogUtil.w( + TAG, + "Failed to resolve contact draft attachment for $contactUri", + throwable, + ) + emit(null) + }.flowOn(ioDispatcher) + } + + override fun deleteTemporaryAttachment(contentUri: String): Flow { + return unitFlow { + val attachmentUri = contentUri.toUri() + if (MediaScratchFileProvider.isMediaScratchSpaceUri(attachmentUri)) { + contentResolver.delete(attachmentUri, null, null) + } + }.catch { throwable -> + if (throwable is CancellationException) { + throw throwable + } + + LogUtil.w(TAG, "Failed to delete temporary attachment $contentUri", throwable) + emit(Unit) + }.flowOn(ioDispatcher) + } + + private fun queryDraftAttachmentFromContact( + contactUri: String, + ): ConversationDraftAttachment? { + val lookupKey = contentResolver.query( + contactUri.toUri(), + arrayOf(Contacts.LOOKUP_KEY), + null, + null, + null, + )?.use { cursor -> + val lookupKeyColumnIndex = cursor.getColumnIndexOrThrow(Contacts.LOOKUP_KEY) + + when { + cursor.moveToFirst() -> cursor.getStringOrNull(lookupKeyColumnIndex) + else -> null + } + } + + if (lookupKey.isNullOrBlank()) { + LogUtil.w(TAG, "Unable to resolve contact lookup key for $contactUri") + return null + } + + val vCardUri = Uri.withAppendedPath( + Contacts.CONTENT_VCARD_URI, + lookupKey, + ) + + return ConversationDraftAttachment( + contentType = ContentType.TEXT_VCARD, + contentUri = vCardUri.toString(), + ) + } + + private companion object { + private const val TAG = "ConversationAttachmentRepository" + } +} diff --git a/src/com/android/messaging/ui/conversation/v2/messages/delegate/ConversationMessagesDelegate.kt b/src/com/android/messaging/ui/conversation/v2/messages/delegate/ConversationMessagesDelegate.kt index 95a24286..24e04dc3 100644 --- a/src/com/android/messaging/ui/conversation/v2/messages/delegate/ConversationMessagesDelegate.kt +++ b/src/com/android/messaging/ui/conversation/v2/messages/delegate/ConversationMessagesDelegate.kt @@ -4,12 +4,14 @@ import com.android.messaging.data.conversation.repository.ConversationsRepositor import com.android.messaging.di.core.DefaultDispatcher import com.android.messaging.ui.conversation.v2.common.ConversationScreenDelegate import com.android.messaging.ui.conversation.v2.messages.mapper.ConversationMessageUiModelMapper +import com.android.messaging.ui.conversation.v2.messages.mapper.ConversationVCardAttachmentUiModelMapper import com.android.messaging.ui.conversation.v2.messages.model.attachment.ConversationVCardAttachmentMetadata import com.android.messaging.ui.conversation.v2.messages.model.message.ConversationMessagePartUiModel import com.android.messaging.ui.conversation.v2.messages.model.message.ConversationMessageUiModel import com.android.messaging.ui.conversation.v2.messages.model.message.ConversationMessagesUiState import com.android.messaging.ui.conversation.v2.messages.repository.ConversationVCardMetadataRepository import javax.inject.Inject +import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope @@ -32,6 +34,7 @@ internal interface ConversationMessagesDelegate : internal class ConversationMessagesDelegateImpl @Inject constructor( private val conversationsRepository: ConversationsRepository, private val conversationMessageUiModelMapper: ConversationMessageUiModelMapper, + private val conversationVCardAttachmentUiModelMapper: ConversationVCardAttachmentUiModelMapper, private val conversationVCardMetadataRepository: ConversationVCardMetadataRepository, @param:DefaultDispatcher private val defaultDispatcher: CoroutineDispatcher, @@ -114,7 +117,7 @@ internal class ConversationMessagesDelegateImpl @Inject constructor( ) } - val vCardAttachmentUiStateFlows = vCardContentUris.map { contentUri -> + val vCardMetadataFlows = vCardContentUris.map { contentUri -> conversationVCardMetadataRepository .observeAttachmentMetadata(contentUri = contentUri) .map { metadata -> @@ -122,55 +125,72 @@ internal class ConversationMessagesDelegateImpl @Inject constructor( } } - return combine(flows = vCardAttachmentUiStateFlows) { contentUriAndUiStates -> - val vCardAttachmentMetadata = contentUriAndUiStates.associate { pair -> + return combine(flows = vCardMetadataFlows) { contentUriAndMetadata -> + val vCardAttachmentMetadata = contentUriAndMetadata.associate { pair -> pair.first to pair.second } ConversationMessagesUiState.Present( - messages = messages - .map { message -> - message.withVCardAttachmentMetadata( - vCardAttachmentMetadata = vCardAttachmentMetadata, - ) - } - .toImmutableList(), + messages = updateMessagesWithVCardUiModel( + messages = messages, + vCardAttachmentMetadata = vCardAttachmentMetadata, + ), ) } } -} -private fun ConversationMessageUiModel.withVCardAttachmentMetadata( - vCardAttachmentMetadata: Map, -): ConversationMessageUiModel { - return copy( - parts = parts.map { part -> - part.withVCardAttachmentMetadata( - vCardAttachmentMetadata = vCardAttachmentMetadata, - ) - }, - ) -} + private fun updateMessagesWithVCardUiModel( + messages: List, + vCardAttachmentMetadata: Map, + ): ImmutableList { + return messages + .map { message -> + updateMessageUiModelWithVCardUiModel( + message = message, + vCardAttachmentMetadata = vCardAttachmentMetadata, + ) + } + .toImmutableList() + } -private fun ConversationMessagePartUiModel.withVCardAttachmentMetadata( - vCardAttachmentMetadata: Map, -): ConversationMessagePartUiModel { - return when (this) { - is ConversationMessagePartUiModel.Attachment.VCard -> { - val contentUri = contentUri?.toString() + private fun updateMessageUiModelWithVCardUiModel( + message: ConversationMessageUiModel, + vCardAttachmentMetadata: Map, + ): ConversationMessageUiModel { + return message.copy( + parts = message.parts.map { part -> + updateMessagePartUiModelWithVCardUiModel( + part = part, + vCardAttachmentMetadata = vCardAttachmentMetadata, + ) + }, + ) + } - copy( - metadata = contentUri?.let(vCardAttachmentMetadata::get), - ) - } + private fun updateMessagePartUiModelWithVCardUiModel( + part: ConversationMessagePartUiModel, + vCardAttachmentMetadata: Map, + ): ConversationMessagePartUiModel { + return when (part) { + is ConversationMessagePartUiModel.Attachment.VCard -> { + val contentUri = part.contentUri?.toString() + val metadata = contentUri?.let(vCardAttachmentMetadata::get) + + part.copy( + vCardUiModel = conversationVCardAttachmentUiModelMapper.map( + metadata = metadata, + ), + ) + } - is ConversationMessagePartUiModel.Attachment.Audio, - is ConversationMessagePartUiModel.Attachment.File, - is ConversationMessagePartUiModel.Attachment.Image, - is ConversationMessagePartUiModel.Attachment.Video, - is ConversationMessagePartUiModel.Text, - -> { - this + is ConversationMessagePartUiModel.Attachment.Audio, + is ConversationMessagePartUiModel.Attachment.File, + is ConversationMessagePartUiModel.Attachment.Image, + is ConversationMessagePartUiModel.Attachment.Video, + is ConversationMessagePartUiModel.Text, + -> { + part + } } } } diff --git a/src/com/android/messaging/ui/conversation/v2/messages/mapper/ConversationMessageUiModelMapper.kt b/src/com/android/messaging/ui/conversation/v2/messages/mapper/ConversationMessageUiModelMapper.kt index 17578413..5e3a5650 100644 --- a/src/com/android/messaging/ui/conversation/v2/messages/mapper/ConversationMessageUiModelMapper.kt +++ b/src/com/android/messaging/ui/conversation/v2/messages/mapper/ConversationMessageUiModelMapper.kt @@ -14,8 +14,9 @@ internal interface ConversationMessageUiModelMapper { fun map(data: ConversationMessageData): ConversationMessageUiModel } -internal class ConversationMessageUiModelMapperImpl @Inject constructor() : - ConversationMessageUiModelMapper { +internal class ConversationMessageUiModelMapperImpl @Inject constructor( + private val conversationVCardAttachmentUiModelMapper: ConversationVCardAttachmentUiModelMapper, +) : ConversationMessageUiModelMapper { override fun map(data: ConversationMessageData): ConversationMessageUiModel { return ConversationMessageUiModel( @@ -83,6 +84,9 @@ internal class ConversationMessageUiModelMapperImpl @Inject constructor() : contentUri = part.contentUri, width = part.width, height = part.height, + vCardUiModel = conversationVCardAttachmentUiModelMapper.map( + metadata = null, + ), ) } diff --git a/src/com/android/messaging/ui/conversation/v2/messages/mapper/ConversationVCardAttachmentUiModelMapper.kt b/src/com/android/messaging/ui/conversation/v2/messages/mapper/ConversationVCardAttachmentUiModelMapper.kt new file mode 100644 index 00000000..fddaa8c7 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/v2/messages/mapper/ConversationVCardAttachmentUiModelMapper.kt @@ -0,0 +1,134 @@ +package com.android.messaging.ui.conversation.v2.messages.mapper + +import com.android.messaging.R +import com.android.messaging.ui.conversation.v2.messages.model.attachment.ConversationVCardAttachmentMetadata +import com.android.messaging.ui.conversation.v2.messages.model.attachment.ConversationVCardAttachmentType +import com.android.messaging.ui.conversation.v2.messages.model.attachment.ConversationVCardAttachmentUiModel +import javax.inject.Inject + +internal interface ConversationVCardAttachmentUiModelMapper { + fun map( + metadata: ConversationVCardAttachmentMetadata?, + ): ConversationVCardAttachmentUiModel +} + +internal class ConversationVCardAttachmentUiModelMapperImpl @Inject constructor() : + ConversationVCardAttachmentUiModelMapper { + + override fun map( + metadata: ConversationVCardAttachmentMetadata?, + ): ConversationVCardAttachmentUiModel { + return mapConversationVCardAttachmentUiModel( + metadata = metadata, + defaultTitleTextResId = R.string.notification_vcard, + defaultSubtitleTextResId = R.string.vcard_tap_hint, + failedSubtitleTextResId = R.string.failed_loading_vcard, + loadingSubtitleTextResId = R.string.loading_vcard, + locationTitleTextResId = R.string.notification_location, + ) + } + + private fun mapConversationVCardAttachmentUiModel( + metadata: ConversationVCardAttachmentMetadata?, + defaultTitleTextResId: Int?, + defaultSubtitleTextResId: Int?, + failedSubtitleTextResId: Int, + loadingSubtitleTextResId: Int, + locationTitleTextResId: Int, + ): ConversationVCardAttachmentUiModel { + return when (metadata) { + ConversationVCardAttachmentMetadata.Failed -> { + createConversationContactUiModel( + titleText = null, + titleTextResId = defaultTitleTextResId, + subtitleText = null, + subtitleTextResId = failedSubtitleTextResId, + ) + } + + ConversationVCardAttachmentMetadata.Loading -> { + createConversationContactUiModel( + titleText = null, + titleTextResId = defaultTitleTextResId, + subtitleText = null, + subtitleTextResId = loadingSubtitleTextResId, + ) + } + + ConversationVCardAttachmentMetadata.Missing, + null, + -> { + createConversationContactUiModel( + titleText = null, + titleTextResId = defaultTitleTextResId, + subtitleText = null, + subtitleTextResId = defaultSubtitleTextResId, + ) + } + + is ConversationVCardAttachmentMetadata.Loaded -> { + mapLoadedConversationVCardAttachmentUiModel( + metadata = metadata, + defaultTitleTextResId = defaultTitleTextResId, + defaultSubtitleTextResId = defaultSubtitleTextResId, + locationTitleTextResId = locationTitleTextResId, + ) + } + } + } + + private fun mapLoadedConversationVCardAttachmentUiModel( + metadata: ConversationVCardAttachmentMetadata.Loaded, + defaultTitleTextResId: Int?, + defaultSubtitleTextResId: Int?, + locationTitleTextResId: Int, + ): ConversationVCardAttachmentUiModel { + return when (metadata.type) { + ConversationVCardAttachmentType.CONTACT -> { + createConversationContactUiModel( + titleText = metadata.displayName, + titleTextResId = if (metadata.displayName == null) { + defaultTitleTextResId + } else { + null + }, + subtitleText = metadata.details, + subtitleTextResId = if (metadata.details == null) { + defaultSubtitleTextResId + } else { + null + }, + ) + } + + ConversationVCardAttachmentType.LOCATION -> { + ConversationVCardAttachmentUiModel( + type = ConversationVCardAttachmentType.LOCATION, + titleText = metadata.displayName, + titleTextResId = if (metadata.displayName == null) { + locationTitleTextResId + } else { + null + }, + subtitleText = metadata.locationAddress ?: metadata.details, + subtitleTextResId = null, + ) + } + } + } + + private fun createConversationContactUiModel( + titleText: String?, + titleTextResId: Int?, + subtitleText: String?, + subtitleTextResId: Int?, + ): ConversationVCardAttachmentUiModel { + return ConversationVCardAttachmentUiModel( + type = ConversationVCardAttachmentType.CONTACT, + titleText = titleText, + titleTextResId = titleTextResId, + subtitleText = subtitleText, + subtitleTextResId = subtitleTextResId, + ) + } +} diff --git a/src/com/android/messaging/ui/conversation/v2/messages/model/attachment/ConversationInlineAttachment.kt b/src/com/android/messaging/ui/conversation/v2/messages/model/attachment/ConversationInlineAttachment.kt index 5df37718..d324e585 100644 --- a/src/com/android/messaging/ui/conversation/v2/messages/model/attachment/ConversationInlineAttachment.kt +++ b/src/com/android/messaging/ui/conversation/v2/messages/model/attachment/ConversationInlineAttachment.kt @@ -30,9 +30,10 @@ internal sealed interface ConversationInlineAttachment { override val key: String, val contentUri: String, override val openAction: ConversationAttachmentOpenAction?, - val subtitleTextResId: Int?, + val type: ConversationVCardAttachmentType, val titleText: String?, val titleTextResId: Int?, - val metadata: ConversationVCardAttachmentMetadata?, + val subtitleText: String?, + val subtitleTextResId: Int?, ) : ConversationInlineAttachment } diff --git a/src/com/android/messaging/ui/conversation/v2/messages/model/attachment/ConversationVCardAttachmentUiState.kt b/src/com/android/messaging/ui/conversation/v2/messages/model/attachment/ConversationVCardAttachmentUiModel.kt similarity index 57% rename from src/com/android/messaging/ui/conversation/v2/messages/model/attachment/ConversationVCardAttachmentUiState.kt rename to src/com/android/messaging/ui/conversation/v2/messages/model/attachment/ConversationVCardAttachmentUiModel.kt index 9165544a..b6ea498a 100644 --- a/src/com/android/messaging/ui/conversation/v2/messages/model/attachment/ConversationVCardAttachmentUiState.kt +++ b/src/com/android/messaging/ui/conversation/v2/messages/model/attachment/ConversationVCardAttachmentUiModel.kt @@ -3,10 +3,12 @@ package com.android.messaging.ui.conversation.v2.messages.model.attachment import androidx.compose.runtime.Immutable @Immutable -internal data class ConversationVCardAttachmentUiState( +internal data class ConversationVCardAttachmentUiModel( val type: ConversationVCardAttachmentType, - val title: String, - val subtitle: String?, + val titleText: String? = null, + val titleTextResId: Int? = null, + val subtitleText: String? = null, + val subtitleTextResId: Int? = null, ) @Immutable diff --git a/src/com/android/messaging/ui/conversation/v2/messages/model/message/ConversationMessagePartUiModel.kt b/src/com/android/messaging/ui/conversation/v2/messages/model/message/ConversationMessagePartUiModel.kt index 0f5d4b96..b025ed3f 100644 --- a/src/com/android/messaging/ui/conversation/v2/messages/model/message/ConversationMessagePartUiModel.kt +++ b/src/com/android/messaging/ui/conversation/v2/messages/model/message/ConversationMessagePartUiModel.kt @@ -2,7 +2,7 @@ package com.android.messaging.ui.conversation.v2.messages.model.message import android.net.Uri import androidx.compose.runtime.Immutable -import com.android.messaging.ui.conversation.v2.messages.model.attachment.ConversationVCardAttachmentMetadata +import com.android.messaging.ui.conversation.v2.messages.model.attachment.ConversationVCardAttachmentUiModel @Immutable internal sealed interface ConversationMessagePartUiModel { @@ -59,7 +59,7 @@ internal sealed interface ConversationMessagePartUiModel { override val contentUri: Uri?, override val width: Int, override val height: Int, - val metadata: ConversationVCardAttachmentMetadata? = null, + val vCardUiModel: ConversationVCardAttachmentUiModel, ) : Attachment @Immutable diff --git a/src/com/android/messaging/ui/conversation/v2/messages/ui/attachment/ConversationAttachmentSectionsBuilder.kt b/src/com/android/messaging/ui/conversation/v2/messages/ui/attachment/ConversationAttachmentSectionsBuilder.kt index fdd514ca..9a2f7d23 100644 --- a/src/com/android/messaging/ui/conversation/v2/messages/ui/attachment/ConversationAttachmentSectionsBuilder.kt +++ b/src/com/android/messaging/ui/conversation/v2/messages/ui/attachment/ConversationAttachmentSectionsBuilder.kt @@ -6,7 +6,7 @@ import com.android.messaging.ui.conversation.v2.messages.model.attachment.Conver import com.android.messaging.ui.conversation.v2.messages.model.attachment.ConversationAttachmentSections import com.android.messaging.ui.conversation.v2.messages.model.attachment.ConversationInlineAttachment import com.android.messaging.ui.conversation.v2.messages.model.attachment.ConversationMessageAttachment -import com.android.messaging.ui.conversation.v2.messages.model.attachment.ConversationVCardAttachmentMetadata +import com.android.messaging.ui.conversation.v2.messages.model.attachment.ConversationVCardAttachmentUiModel import com.android.messaging.ui.conversation.v2.messages.model.message.ConversationMessagePartUiModel import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList @@ -131,7 +131,7 @@ private fun toMediaInlineAttachment( key = attachment.key, contentUri = part.contentUri.toString(), openAction = attachment.toConversationAttachmentOpenActionOrNull(), - vCardAttachmentMetadata = part.metadata, + vCardUiModel = part.vCardUiModel, ) } @@ -167,16 +167,17 @@ private fun createVCardInlineAttachment( key: String, contentUri: String, openAction: ConversationAttachmentOpenAction?, - vCardAttachmentMetadata: ConversationVCardAttachmentMetadata?, + vCardUiModel: ConversationVCardAttachmentUiModel, ): ConversationInlineAttachment { return ConversationInlineAttachment.VCard( key = key, contentUri = contentUri, openAction = openAction, - subtitleTextResId = R.string.vcard_tap_hint, - titleText = null, - titleTextResId = R.string.notification_vcard, - metadata = vCardAttachmentMetadata, + type = vCardUiModel.type, + titleText = vCardUiModel.titleText, + titleTextResId = vCardUiModel.titleTextResId, + subtitleText = vCardUiModel.subtitleText, + subtitleTextResId = vCardUiModel.subtitleTextResId, ) } diff --git a/src/com/android/messaging/ui/conversation/v2/messages/ui/attachment/ConversationVCardInlineAttachmentRow.kt b/src/com/android/messaging/ui/conversation/v2/messages/ui/attachment/ConversationVCardInlineAttachmentRow.kt index 82efe5c1..f1e20fea 100644 --- a/src/com/android/messaging/ui/conversation/v2/messages/ui/attachment/ConversationVCardInlineAttachmentRow.kt +++ b/src/com/android/messaging/ui/conversation/v2/messages/ui/attachment/ConversationVCardInlineAttachmentRow.kt @@ -21,11 +21,8 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp -import com.android.messaging.R import com.android.messaging.ui.conversation.v2.messages.model.attachment.ConversationInlineAttachment -import com.android.messaging.ui.conversation.v2.messages.model.attachment.ConversationVCardAttachmentMetadata import com.android.messaging.ui.conversation.v2.messages.model.attachment.ConversationVCardAttachmentType -import com.android.messaging.ui.conversation.v2.messages.model.attachment.ConversationVCardAttachmentUiState @Composable internal fun ConversationVCardInlineAttachmentRow( @@ -35,8 +32,6 @@ internal fun ConversationVCardInlineAttachmentRow( onExternalUriClick: (String) -> Unit, onLongClick: () -> Unit, ) { - val uiState = attachment.toConversationVCardAttachmentUiState() - val onClick = attachment.openAction?.let { action -> { dispatchConversationAttachmentOpenAction( @@ -48,113 +43,16 @@ internal fun ConversationVCardInlineAttachmentRow( } ConversationVCardInlineAttachmentRowContent( - uiState = uiState, + attachment = attachment, isSelectionMode = isSelectionMode, onClick = onClick, onLongClick = onLongClick, ) } -@Composable -private fun ConversationInlineAttachment.VCard.toConversationVCardAttachmentUiState(): - ConversationVCardAttachmentUiState { - return metadata.toConversationVCardAttachmentUiState( - defaultUiText = resolveConversationVCardDefaultUiText(), - ) -} - -@Composable -private fun ConversationInlineAttachment.VCard.resolveConversationVCardDefaultUiText(): - ConversationVCardDefaultUiText { - val defaultTitle = titleText - ?: titleTextResId?.let { titleTextResId -> - stringResource(id = titleTextResId) - } - ?: stringResource(id = R.string.notification_vcard) - - val defaultSubtitle = subtitleTextResId?.let { subtitleTextResId -> - stringResource(id = subtitleTextResId) - } ?: stringResource(id = R.string.vcard_tap_hint) - - return ConversationVCardDefaultUiText( - defaultTitle = defaultTitle, - defaultSubtitle = defaultSubtitle, - loadingSubtitle = stringResource(id = R.string.loading_vcard), - failedSubtitle = stringResource(id = R.string.failed_loading_vcard), - locationTitle = stringResource(id = R.string.notification_location), - ) -} - -private fun ConversationVCardAttachmentMetadata?.toConversationVCardAttachmentUiState( - defaultUiText: ConversationVCardDefaultUiText, -): ConversationVCardAttachmentUiState { - return when (this) { - ConversationVCardAttachmentMetadata.Failed -> { - createConversationContactUiState( - title = defaultUiText.defaultTitle, - subtitle = defaultUiText.failedSubtitle, - ) - } - - ConversationVCardAttachmentMetadata.Loading -> { - createConversationContactUiState( - title = defaultUiText.defaultTitle, - subtitle = defaultUiText.loadingSubtitle, - ) - } - - ConversationVCardAttachmentMetadata.Missing, - null, - -> { - createConversationContactUiState( - title = defaultUiText.defaultTitle, - subtitle = defaultUiText.defaultSubtitle, - ) - } - - is ConversationVCardAttachmentMetadata.Loaded -> { - toConversationLoadedVCardAttachmentUiState( - defaultUiText = defaultUiText, - ) - } - } -} - -private fun ConversationVCardAttachmentMetadata.Loaded.toConversationLoadedVCardAttachmentUiState( - defaultUiText: ConversationVCardDefaultUiText, -): ConversationVCardAttachmentUiState { - return when (type) { - ConversationVCardAttachmentType.CONTACT -> { - createConversationContactUiState( - title = displayName ?: defaultUiText.defaultTitle, - subtitle = details ?: defaultUiText.defaultSubtitle, - ) - } - - ConversationVCardAttachmentType.LOCATION -> { - ConversationVCardAttachmentUiState( - type = ConversationVCardAttachmentType.LOCATION, - title = displayName ?: defaultUiText.locationTitle, - subtitle = locationAddress ?: details, - ) - } - } -} - -private fun createConversationContactUiState( - title: String, - subtitle: String?, -): ConversationVCardAttachmentUiState { - return ConversationVCardAttachmentUiState( - type = ConversationVCardAttachmentType.CONTACT, - title = title, - subtitle = subtitle, - ) -} - @Composable internal fun ConversationVCardInlineAttachmentRowContent( - uiState: ConversationVCardAttachmentUiState, + attachment: ConversationInlineAttachment.VCard, isSelectionMode: Boolean, onClick: (() -> Unit)?, onLongClick: () -> Unit, @@ -178,51 +76,94 @@ internal fun ConversationVCardInlineAttachmentRowContent( color = MaterialTheme.colorScheme.surfaceContainerHighest, shape = RoundedCornerShape(size = MESSAGE_ATTACHMENT_CORNER_RADIUS), ) { - Row( + ConversationVCardAttachmentCardContent( modifier = Modifier .fillMaxWidth() .padding(horizontal = 14.dp, vertical = 12.dp), - horizontalArrangement = Arrangement.spacedBy(space = 12.dp), - verticalAlignment = Alignment.CenterVertically, + type = attachment.type, + titleText = attachment.titleText, + titleTextResId = attachment.titleTextResId, + subtitleText = attachment.subtitleText, + subtitleTextResId = attachment.subtitleTextResId, + ) + } +} + +@Composable +internal fun ConversationVCardAttachmentCardContent( + modifier: Modifier = Modifier, + type: ConversationVCardAttachmentType, + titleText: String?, + titleTextResId: Int?, + subtitleText: String?, + subtitleTextResId: Int?, +) { + val title = resolveTitleText( + titleText = titleText, + titleTextResId = titleTextResId, + ) + + val subtitle = resolveSubtitleText( + subtitleText = subtitleText, + subtitleTextResId = subtitleTextResId, + ) + + Row( + modifier = modifier, + horizontalArrangement = Arrangement.spacedBy(space = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier.size(size = 28.dp), + contentAlignment = Alignment.Center, ) { - Box( - modifier = Modifier.size(size = 28.dp), - contentAlignment = Alignment.Center, - ) { - Icon( - imageVector = when (uiState.type) { - ConversationVCardAttachmentType.CONTACT -> Icons.Rounded.Person - ConversationVCardAttachmentType.LOCATION -> Icons.Rounded.Place - }, - contentDescription = null, - ) - } + Icon( + imageVector = when (type) { + ConversationVCardAttachmentType.CONTACT -> Icons.Rounded.Person + ConversationVCardAttachmentType.LOCATION -> Icons.Rounded.Place + }, + contentDescription = null, + ) + } + + Column( + verticalArrangement = Arrangement.spacedBy(space = 2.dp), + ) { + Text( + text = title, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + ) - Column( - verticalArrangement = Arrangement.spacedBy(space = 2.dp), - ) { + subtitle?.let { subtitleText -> Text( - text = uiState.title, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface, + text = subtitleText, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, ) - - uiState.subtitle?.let { subtitle -> - Text( - text = subtitle, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } } } } } -private data class ConversationVCardDefaultUiText( - val defaultTitle: String, - val defaultSubtitle: String, - val loadingSubtitle: String, - val failedSubtitle: String, - val locationTitle: String, -) +@Composable +private fun resolveTitleText( + titleText: String?, + titleTextResId: Int?, +): String { + return titleText + ?: titleTextResId?.let { titleResId -> + stringResource(titleResId) + } + .orEmpty() +} + +@Composable +private fun resolveSubtitleText( + subtitleText: String?, + subtitleTextResId: Int?, +): String? { + return subtitleText ?: subtitleTextResId?.let { subtitleResId -> + stringResource(subtitleResId) + } +} diff --git a/src/com/android/messaging/ui/conversation/v2/screen/ConversationScreen.kt b/src/com/android/messaging/ui/conversation/v2/screen/ConversationScreen.kt index 57280b10..77fb63f5 100644 --- a/src/com/android/messaging/ui/conversation/v2/screen/ConversationScreen.kt +++ b/src/com/android/messaging/ui/conversation/v2/screen/ConversationScreen.kt @@ -1,6 +1,8 @@ package com.android.messaging.ui.conversation.v2.screen import androidx.activity.compose.BackHandler +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize @@ -35,7 +37,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.android.messaging.R import com.android.messaging.data.conversation.model.draft.ConversationDraft import com.android.messaging.ui.conversation.v2.CONVERSATION_LOADING_INDICATOR_TEST_TAG -import com.android.messaging.ui.conversation.v2.composer.model.ConversationComposerAttachmentUiState +import com.android.messaging.ui.conversation.v2.composer.model.ComposerAttachmentUiModel import com.android.messaging.ui.conversation.v2.composer.ui.ConversationComposerSection import com.android.messaging.ui.conversation.v2.composer.ui.ConversationSimSelectorSheet import com.android.messaging.ui.conversation.v2.entry.model.ConversationEntryStartupAttachment @@ -76,6 +78,11 @@ internal fun ConversationScreen( val hostBoundsState = remember { mutableStateOf(value = null) } + val contactPickerLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.PickContact(), + ) { contactUri -> + screenModel.onContactCardPicked(contactUri = contactUri?.toString()) + } LaunchedEffect(conversationId) { screenModel.onConversationIdChanged(conversationId = conversationId) @@ -161,6 +168,9 @@ internal fun ConversationScreen( onMessageClick = screenModel::onMessageClick, onMessageLongClick = screenModel::onMessageLongClick, onMessageSelectionActionClick = screenModel::onMessageSelectionActionClick, + onOpenContactPicker = { + contactPickerLauncher.launch(input = null) + }, onOpenMediaPicker = mediaPickerState::open, onMessageTextChange = screenModel::onMessageTextChanged, onPendingAttachmentRemove = screenModel::onRemovePendingAttachment, @@ -181,7 +191,9 @@ internal fun ConversationScreen( conversationTitle = mediaPickerOverlayUiState.conversationTitle, isSendActionEnabled = mediaPickerOverlayUiState.isSendActionEnabled, messageFieldFocusRequester = messageFieldFocusRequester, - onAttachmentPreviewClick = screenModel::onAttachmentClicked, + onAttachmentPreviewClick = { attachment -> + screenModel.onAttachmentClicked(attachment = attachment) + }, onAttachmentCaptionChange = screenModel::onUpdateAttachmentCaption, onAttachmentRemove = screenModel::onRemoveResolvedAttachment, onGalleryMediaConfirmed = screenModel::onGalleryMediaConfirmed, @@ -215,10 +227,11 @@ private fun ConversationScreenScaffold( onMessageLongClick: (String) -> Unit, onMessageSelectionActionClick: (ConversationMessageSelectionAction) -> Unit, onNavigateBack: () -> Unit, + onOpenContactPicker: () -> Unit, onOpenMediaPicker: () -> Unit, onMessageTextChange: (String) -> Unit, onPendingAttachmentRemove: (String) -> Unit, - onResolvedAttachmentClick: (ConversationComposerAttachmentUiState.Resolved) -> Unit, + onResolvedAttachmentClick: (ComposerAttachmentUiModel.Resolved) -> Unit, onResolvedAttachmentRemove: (String) -> Unit, onSendClick: () -> Unit, onSimSelected: (String) -> Unit, @@ -278,7 +291,8 @@ private fun ConversationScreenScaffold( isAttachmentActionEnabled = uiState.composer.isAttachmentActionEnabled, isSendActionEnabled = uiState.composer.isSendEnabled, messageFieldFocusRequester = messageFieldFocusRequester, - onAttachmentClick = onOpenMediaPicker, + onContactAttachClick = onOpenContactPicker, + onMediaPickerClick = onOpenMediaPicker, onMessageTextChange = onMessageTextChange, onPendingAttachmentRemove = onPendingAttachmentRemove, onResolvedAttachmentClick = onResolvedAttachmentClick, @@ -320,11 +334,9 @@ private fun ConversationScreenScaffold( uiState = uiState.composer.simSelector, onSimSelected = { selfParticipantId -> onSimSelected(selfParticipantId) - @Suppress("AssignedValueIsNeverRead") isSimSheetVisible = false }, onDismissRequest = { - @Suppress("AssignedValueIsNeverRead") isSimSheetVisible = false }, ) diff --git a/src/com/android/messaging/ui/conversation/v2/screen/ConversationViewModel.kt b/src/com/android/messaging/ui/conversation/v2/screen/ConversationViewModel.kt index 6aa6f1d2..3ebc62bc 100644 --- a/src/com/android/messaging/ui/conversation/v2/screen/ConversationViewModel.kt +++ b/src/com/android/messaging/ui/conversation/v2/screen/ConversationViewModel.kt @@ -10,9 +10,10 @@ import com.android.messaging.datamodel.MessagingContentProvider import com.android.messaging.di.core.DefaultDispatcher import com.android.messaging.domain.conversation.usecase.CanAddMoreConversationParticipants import com.android.messaging.domain.conversation.usecase.IsDeviceVoiceCapable +import com.android.messaging.ui.conversation.v2.composer.delegate.ConversationComposerAttachmentsDelegate import com.android.messaging.ui.conversation.v2.composer.delegate.ConversationDraftDelegate import com.android.messaging.ui.conversation.v2.composer.mapper.ConversationComposerUiStateMapper -import com.android.messaging.ui.conversation.v2.composer.model.ConversationComposerAttachmentUiState +import com.android.messaging.ui.conversation.v2.composer.model.ComposerAttachmentUiModel import com.android.messaging.ui.conversation.v2.composer.model.ConversationComposerUiState import com.android.messaging.ui.conversation.v2.entry.model.ConversationEntryStartupAttachment import com.android.messaging.ui.conversation.v2.mediapicker.ConversationMediaPickerDelegate @@ -57,7 +58,7 @@ internal interface ConversationScreenModel { ) fun onAttachmentClicked( - attachment: ConversationComposerAttachmentUiState.Resolved, + attachment: ComposerAttachmentUiModel.Resolved, ) fun onMessageAttachmentClicked( @@ -76,6 +77,7 @@ internal interface ConversationScreenModel { fun onExternalUriClicked(uri: String) fun onGalleryMediaConfirmed(mediaItems: List) + fun onContactCardPicked(contactUri: String?) fun onMessageTextChanged(text: String) fun onGalleryVisibilityChanged(isVisible: Boolean) fun onCapturedMediaReady(capturedMedia: ConversationCapturedMedia) @@ -102,6 +104,7 @@ internal interface ConversationScreenModel { @HiltViewModel internal class ConversationViewModel @Inject constructor( + private val conversationComposerAttachmentsDelegate: ConversationComposerAttachmentsDelegate, private val conversationDraftDelegate: ConversationDraftDelegate, private val conversationMessagesDelegate: ConversationMessagesDelegate, private val conversationMessageSelectionDelegate: ConversationMessageSelectionDelegate, @@ -137,13 +140,19 @@ internal class ConversationViewModel @Inject constructor( initialValue = persistentListOf(), ) + init { + initializeDelegates() + } + private val composerUiState = combine( conversationMetadataDelegate.state, conversationDraftDelegate.state, + conversationComposerAttachmentsDelegate.state, subscriptionsFlow, - ) { metadataState, draftState, subscriptions -> + ) { metadataState, draftState, attachments, subscriptions -> conversationComposerUiStateMapper.map( draftState = draftState, + attachments = attachments, composerAvailability = metadataState.composerAvailability, subscriptions = subscriptions, ) @@ -154,6 +163,7 @@ internal class ConversationViewModel @Inject constructor( ), initialValue = conversationComposerUiStateMapper.map( draftState = conversationDraftDelegate.state.value, + attachments = conversationComposerAttachmentsDelegate.state.value, composerAvailability = conversationMetadataDelegate.state.value.composerAvailability, subscriptions = subscriptionsFlow.value, ), @@ -213,45 +223,44 @@ internal class ConversationViewModel @Inject constructor( ) } - override val mediaPickerOverlayUiState: StateFlow = - combine( - conversationMetadataDelegate.state, - conversationMediaPickerDelegate.state, - composerUiState, - ) { metadataState, mediaPickerUiState, composerUiState -> - val conversationTitle = when (metadataState) { - is ConversationMetadataUiState.Present -> metadataState.title - else -> null - } + override val mediaPickerOverlayUiState = combine( + conversationMetadataDelegate.state, + conversationMediaPickerDelegate.state, + composerUiState, + ) { metadataState, mediaPickerUiState, composerUiState -> + val conversationTitle = when (metadataState) { + is ConversationMetadataUiState.Present -> metadataState.title + else -> null + } - ConversationMediaPickerOverlayUiState( - mediaPicker = mediaPickerUiState, - attachments = composerUiState.attachments, - conversationTitle = conversationTitle, - isSendActionEnabled = composerUiState.isSendEnabled, - ) - }.stateIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed( - stopTimeoutMillis = STATEFLOW_STOP_TIMEOUT_MILLIS, - ), - initialValue = ConversationMediaPickerOverlayUiState( - mediaPicker = conversationMediaPickerDelegate.state.value, - attachments = composerUiState.value.attachments, - conversationTitle = null, - isSendActionEnabled = composerUiState.value.isSendEnabled, - ), + ConversationMediaPickerOverlayUiState( + mediaPicker = mediaPickerUiState, + attachments = composerUiState.attachments, + conversationTitle = conversationTitle, + isSendActionEnabled = composerUiState.isSendEnabled, ) - - init { - initializeDelegates() - } + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed( + stopTimeoutMillis = STATEFLOW_STOP_TIMEOUT_MILLIS, + ), + initialValue = ConversationMediaPickerOverlayUiState( + mediaPicker = conversationMediaPickerDelegate.state.value, + attachments = composerUiState.value.attachments, + conversationTitle = null, + isSendActionEnabled = composerUiState.value.isSendEnabled, + ), + ) private fun initializeDelegates() { conversationDraftDelegate.bind( scope = viewModelScope, conversationIdFlow = conversationIdFlow, ) + conversationComposerAttachmentsDelegate.bind( + scope = viewModelScope, + draftStateFlow = conversationDraftDelegate.state, + ) conversationMediaPickerDelegate.bind( scope = viewModelScope, conversationIdFlow = conversationIdFlow, @@ -361,12 +370,9 @@ internal class ConversationViewModel @Inject constructor( } } - override fun onAttachmentClicked( - attachment: ConversationComposerAttachmentUiState.Resolved, - ) { - val conversationId = conversationIdFlow.value - - val imageCollectionUri = conversationId + override fun onAttachmentClicked(attachment: ComposerAttachmentUiModel.Resolved) { + val imageCollectionUri = conversationIdFlow + .value ?.let(MessagingContentProvider::buildDraftImagesUri) ?.toString() @@ -385,9 +391,8 @@ internal class ConversationViewModel @Inject constructor( contentType: String, contentUri: String, ) { - val conversationId = conversationIdFlow.value - - val imageCollectionUri = conversationId + val imageCollectionUri = conversationIdFlow + .value ?.let(MessagingContentProvider::buildConversationImagesUri) ?.toString() @@ -451,6 +456,10 @@ internal class ConversationViewModel @Inject constructor( conversationMediaPickerDelegate.onGalleryMediaConfirmed(mediaItems = mediaItems) } + override fun onContactCardPicked(contactUri: String?) { + conversationMediaPickerDelegate.onContactCardPicked(contactUri = contactUri) + } + override fun onMessageTextChanged(text: String) { conversationDraftDelegate.onMessageTextChanged(messageText = text) } diff --git a/src/com/android/messaging/ui/conversation/v2/screen/model/ConversationMediaPickerOverlayUiState.kt b/src/com/android/messaging/ui/conversation/v2/screen/model/ConversationMediaPickerOverlayUiState.kt index 65180ff6..538f8535 100644 --- a/src/com/android/messaging/ui/conversation/v2/screen/model/ConversationMediaPickerOverlayUiState.kt +++ b/src/com/android/messaging/ui/conversation/v2/screen/model/ConversationMediaPickerOverlayUiState.kt @@ -1,7 +1,7 @@ package com.android.messaging.ui.conversation.v2.screen.model import androidx.compose.runtime.Immutable -import com.android.messaging.ui.conversation.v2.composer.model.ConversationComposerAttachmentUiState +import com.android.messaging.ui.conversation.v2.composer.model.ComposerAttachmentUiModel import com.android.messaging.ui.conversation.v2.mediapicker.model.ConversationMediaPickerUiState import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf @@ -9,7 +9,7 @@ import kotlinx.collections.immutable.persistentListOf @Immutable internal data class ConversationMediaPickerOverlayUiState( val mediaPicker: ConversationMediaPickerUiState = ConversationMediaPickerUiState(), - val attachments: ImmutableList = persistentListOf(), + val attachments: ImmutableList = persistentListOf(), val conversationTitle: String? = null, val isSendActionEnabled: Boolean = false, ) From 654b3f67966cdeb8b26cd1bce70a605279ad4214 Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Wed, 22 Apr 2026 14:23:12 +0300 Subject: [PATCH 53/99] Remove contacts picker from back stack after navigation to conversation --- .../ConversationNavigationReducer.kt | 26 ++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/src/com/android/messaging/ui/conversation/v2/navigation/ConversationNavigationReducer.kt b/src/com/android/messaging/ui/conversation/v2/navigation/ConversationNavigationReducer.kt index 21c50024..37c9ac69 100644 --- a/src/com/android/messaging/ui/conversation/v2/navigation/ConversationNavigationReducer.kt +++ b/src/com/android/messaging/ui/conversation/v2/navigation/ConversationNavigationReducer.kt @@ -46,9 +46,15 @@ internal class ConversationNavigationReducerImpl : ConversationNavigationReducer backStack: MutableList, conversationId: String, ) { - ConversationNavKey(conversationId = conversationId) - .takeIf { it != backStack.lastOrNull() } - ?.let(backStack::add) + removeTrailingConversationEntryDestinations(backStack = backStack) + + val destination = ConversationNavKey(conversationId = conversationId) + + if (destination == backStack.lastOrNull()) { + return + } + + backStack.add(destination) } override fun navigateToRecipientPicker( @@ -101,4 +107,18 @@ internal class ConversationNavigationReducerImpl : ConversationNavigationReducer backStack.clear() backStack.add(destination) } + + private fun removeTrailingConversationEntryDestinations(backStack: MutableList) { + while (backStack.lastOrNull().isConversationEntryDestination()) { + backStack.removeAt(backStack.lastIndex) + } + } + + private fun NavKey?.isConversationEntryDestination(): Boolean { + return when (this) { + NewChatNavKey -> true + is RecipientPickerNavKey -> true + else -> false + } + } } From b63cc310308cecae1e872755b5dbf484b70d2d0a Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Wed, 22 Apr 2026 22:02:43 +0300 Subject: [PATCH 54/99] Add audio recording attachments --- res/values/strings.xml | 1 + .../draft/ConversationDraftAttachment.kt | 2 + .../ConversationDraftsRepository.kt | 77 ++- .../ConversationSubscriptionsRepository.kt | 48 ++ .../ConversationViewModelBindsModule.kt | 8 + .../conversation/v2/ConversationTestTags.kt | 3 + .../ConversationAudioDurationFormatter.kt | 16 + .../ConversationAudioRecordingDelegate.kt | 209 ++++++++ .../ConversationAudioRecordingUiState.kt | 9 + ...ersationComposerAttachmentUiModelMapper.kt | 1 + .../ConversationComposerUiStateMapper.kt | 11 + .../model/ComposerAttachmentUiModel.kt | 1 + .../model/ConversationComposerUiState.kt | 4 + .../ui/ConversationAttachmentPreview.kt | 106 +++- .../ui/ConversationAudioRecordingBar.kt | 291 +++++++++++ .../v2/composer/ui/ConversationComposeBar.kt | 213 ++++++-- .../ui/ConversationComposerSection.kt | 13 + .../ui/ConversationSendActionButton.kt | 457 +++++++++++++++++- .../ConversationMediaCaptureControls.kt | 12 +- .../review/ConversationMediaPickerReview.kt | 6 + ...ationInlineAudioAttachmentPlaybackState.kt | 7 +- .../v2/screen/ConversationScreen.kt | 53 ++ .../v2/screen/ConversationViewModel.kt | 35 +- 23 files changed, 1519 insertions(+), 64 deletions(-) create mode 100644 src/com/android/messaging/ui/conversation/v2/audio/ConversationAudioDurationFormatter.kt create mode 100644 src/com/android/messaging/ui/conversation/v2/audio/delegate/ConversationAudioRecordingDelegate.kt create mode 100644 src/com/android/messaging/ui/conversation/v2/audio/model/ConversationAudioRecordingUiState.kt create mode 100644 src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationAudioRecordingBar.kt diff --git a/res/values/strings.xml b/res/values/strings.xml index 13599802..13b02f0f 100644 --- a/res/values/strings.xml +++ b/res/values/strings.xml @@ -602,6 +602,7 @@ Send photo Send photos + Slide to cancel Send audio diff --git a/src/com/android/messaging/data/conversation/model/draft/ConversationDraftAttachment.kt b/src/com/android/messaging/data/conversation/model/draft/ConversationDraftAttachment.kt index af20005b..5f21d973 100644 --- a/src/com/android/messaging/data/conversation/model/draft/ConversationDraftAttachment.kt +++ b/src/com/android/messaging/data/conversation/model/draft/ConversationDraftAttachment.kt @@ -1,9 +1,11 @@ package com.android.messaging.data.conversation.model.draft +// TODO: Probably should be sealed interface and mapped to types on this stage internal data class ConversationDraftAttachment( val contentType: String, val contentUri: String, val captionText: String = "", val width: Int? = null, val height: Int? = null, + val durationMillis: Long? = null, ) diff --git a/src/com/android/messaging/data/conversation/repository/ConversationDraftsRepository.kt b/src/com/android/messaging/data/conversation/repository/ConversationDraftsRepository.kt index fe6d0cb0..568d5ab1 100644 --- a/src/com/android/messaging/data/conversation/repository/ConversationDraftsRepository.kt +++ b/src/com/android/messaging/data/conversation/repository/ConversationDraftsRepository.kt @@ -2,15 +2,20 @@ package com.android.messaging.data.conversation.repository import android.content.ContentResolver import android.database.ContentObserver +import android.media.MediaMetadataRetriever import android.net.Uri +import androidx.core.net.toUri import com.android.messaging.data.conversation.mapper.ConversationDraftMessageDataMapper import com.android.messaging.data.conversation.mapper.ConversationMessageDataDraftMapper import com.android.messaging.data.conversation.model.draft.ConversationDraft import com.android.messaging.datamodel.MessagingContentProvider import com.android.messaging.datamodel.data.MessageData import com.android.messaging.di.core.IoDispatcher +import com.android.messaging.util.ContentType import com.android.messaging.util.LogUtil +import com.android.messaging.util.MediaMetadataRetrieverWrapper import javax.inject.Inject +import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.flow.Flow @@ -128,14 +133,80 @@ internal class ConversationDraftsRepositoryImpl @Inject constructor( } else -> { - conversationMessageDataDraftMapper.map( - messageData = draftMessage, - fallbackSelfParticipantId = conversation.selfParticipantId, + resolveDraftAttachmentMetadata( + draft = conversationMessageDataDraftMapper.map( + messageData = draftMessage, + fallbackSelfParticipantId = conversation.selfParticipantId, + ), ) } } } + private fun resolveDraftAttachmentMetadata(draft: ConversationDraft): ConversationDraft { + val hasAudioAttachments = draft.attachments.any { attachment -> + ContentType.isAudioType(attachment.contentType) + } + + return when { + hasAudioAttachments -> resolveDraftAudioMetadata(draft = draft) + else -> draft + } + } + + private fun resolveDraftAudioMetadata(draft: ConversationDraft): ConversationDraft { + var hasChanges = false + + val resolvedAttachments = draft + .attachments + .map { attachment -> + val isAudio = ContentType.isAudioType(attachment.contentType) + + if (!isAudio || attachment.durationMillis != null) { + return@map attachment + } + + hasChanges = true + attachment.copy( + durationMillis = resolveAudioDurationMillis( + contentUri = attachment.contentUri, + ), + ) + } + + return when { + hasChanges -> { + draft.copy( + attachments = resolvedAttachments.toImmutableList(), + ) + } + else -> draft + } + } + + private fun resolveAudioDurationMillis(contentUri: String): Long { + val mediaMetadataRetrieverWrapper = MediaMetadataRetrieverWrapper() + + return try { + mediaMetadataRetrieverWrapper.setDataSource(contentUri.toUri()) + mediaMetadataRetrieverWrapper + .extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION) + ?.toLongOrNull() + ?.coerceAtLeast(minimumValue = 0L) + ?: 0L + } catch (throwable: Throwable) { + LogUtil.w( + TAG, + "Failed to resolve draft audio duration for $contentUri", + throwable, + ) + + 0L + } finally { + mediaMetadataRetrieverWrapper.release() + } + } + private fun bindDraftParticipantsIfNeeded( conversationId: String, message: MessageData, diff --git a/src/com/android/messaging/data/conversation/repository/ConversationSubscriptionsRepository.kt b/src/com/android/messaging/data/conversation/repository/ConversationSubscriptionsRepository.kt index 5f762967..219e131d 100644 --- a/src/com/android/messaging/data/conversation/repository/ConversationSubscriptionsRepository.kt +++ b/src/com/android/messaging/data/conversation/repository/ConversationSubscriptionsRepository.kt @@ -11,14 +11,19 @@ import com.android.messaging.datamodel.data.ParticipantData import com.android.messaging.debug.DebugSimEmulationMode import com.android.messaging.debug.DebugSimEmulationSource import com.android.messaging.di.core.IoDispatcher +import com.android.messaging.sms.MmsConfig +import com.android.messaging.util.LogUtil +import com.android.messaging.util.core.extension.typedFlow import javax.inject.Inject import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.conflate import kotlinx.coroutines.flow.flowOn @@ -26,6 +31,8 @@ import kotlinx.coroutines.flow.map internal interface ConversationSubscriptionsRepository { fun observeActiveSubscriptions(): Flow> + + fun resolveMaxMessageSize(selfParticipantId: String): Flow } internal class ConversationSubscriptionsRepositoryImpl @Inject constructor( @@ -56,6 +63,19 @@ internal class ConversationSubscriptionsRepositoryImpl @Inject constructor( } } + override fun resolveMaxMessageSize(selfParticipantId: String): Flow { + return typedFlow { + queryMaxMessageSize(selfParticipantId = selfParticipantId) + }.catch { throwable -> + if (throwable is CancellationException) { + throw throwable + } + + LogUtil.w(TAG, "Failed to resolve max message size", throwable) + emit(MmsConfig.getMaxMaxMessageSize()) + }.flowOn(ioDispatcher) + } + private fun applyDebugEmulation( subscriptions: ImmutableList, mode: DebugSimEmulationMode, @@ -177,7 +197,35 @@ internal class ConversationSubscriptionsRepositoryImpl @Inject constructor( ?: persistentListOf() } + private fun queryMaxMessageSize( + selfParticipantId: String, + ): Int { + if (selfParticipantId.isBlank()) { + return MmsConfig.getMaxMaxMessageSize() + } + + val resolvedSubId = contentResolver.query( + MessagingContentProvider.PARTICIPANTS_URI, + ParticipantData.ParticipantsQuery.PROJECTION, + "${ParticipantColumns._ID} = ?", + arrayOf(selfParticipantId), + null, + )?.use { cursor -> + when { + cursor.moveToFirst() -> ParticipantData.getFromCursor(cursor).subId + else -> null + } + } ?: return MmsConfig.getMaxMaxMessageSize() + + if (resolvedSubId <= ParticipantData.DEFAULT_SELF_SUB_ID) { + return MmsConfig.getMaxMaxMessageSize() + } + + return MmsConfig.get(resolvedSubId).maxMessageSize + } + private companion object { + private const val TAG = "ConversationSubscriptionsRepo" private const val FAKE_SIM_ID_PREFIX = "debug_sim_emulated_" private val FAKE_SIM_COLORS = intArrayOf( 0xFF5E9BE8.toInt(), diff --git a/src/com/android/messaging/di/conversation/ConversationViewModelBindsModule.kt b/src/com/android/messaging/di/conversation/ConversationViewModelBindsModule.kt index 733afb2b..564e9fbc 100644 --- a/src/com/android/messaging/di/conversation/ConversationViewModelBindsModule.kt +++ b/src/com/android/messaging/di/conversation/ConversationViewModelBindsModule.kt @@ -1,5 +1,7 @@ package com.android.messaging.di.conversation +import com.android.messaging.ui.conversation.v2.audio.delegate.ConversationAudioRecordingDelegate +import com.android.messaging.ui.conversation.v2.audio.delegate.ConversationAudioRecordingDelegateImpl import com.android.messaging.ui.conversation.v2.composer.delegate.ConversationComposerAttachmentsDelegate import com.android.messaging.ui.conversation.v2.composer.delegate.ConversationComposerAttachmentsDelegateImpl import com.android.messaging.ui.conversation.v2.composer.delegate.ConversationDraftDelegate @@ -24,6 +26,12 @@ import dagger.hilt.android.scopes.ViewModelScoped @InstallIn(ViewModelComponent::class) internal abstract class ConversationViewModelBindsModule { + @Binds + @ViewModelScoped + abstract fun bindConversationAudioRecordingDelegate( + impl: ConversationAudioRecordingDelegateImpl, + ): ConversationAudioRecordingDelegate + @Binds @ViewModelScoped abstract fun bindConversationComposerAttachmentsDelegate( diff --git a/src/com/android/messaging/ui/conversation/v2/ConversationTestTags.kt b/src/com/android/messaging/ui/conversation/v2/ConversationTestTags.kt index 541e6340..5726ec18 100644 --- a/src/com/android/messaging/ui/conversation/v2/ConversationTestTags.kt +++ b/src/com/android/messaging/ui/conversation/v2/ConversationTestTags.kt @@ -26,6 +26,9 @@ internal const val CONVERSATION_INLINE_AUDIO_ATTACHMENT_PLAY_BUTTON_TEST_TAG = "conversation_inline_audio_attachment_play_button" internal const val CONVERSATION_INLINE_AUDIO_ATTACHMENT_PROGRESS_TEST_TAG = "conversation_inline_audio_attachment_progress" +internal const val CONVERSATION_AUDIO_RECORDING_BAR_TEST_TAG = "conversation_audio_recording_bar" +internal const val CONVERSATION_AUDIO_RECORDING_CANCEL_BUTTON_TEST_TAG = + "conversation_audio_recording_cancel_button" internal const val ADD_PARTICIPANTS_CONFIRM_BUTTON_TEST_TAG = "add_participants_confirm_button" internal const val NEW_CHAT_CREATE_GROUP_NEXT_BUTTON_TEST_TAG = diff --git a/src/com/android/messaging/ui/conversation/v2/audio/ConversationAudioDurationFormatter.kt b/src/com/android/messaging/ui/conversation/v2/audio/ConversationAudioDurationFormatter.kt new file mode 100644 index 00000000..795806d9 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/v2/audio/ConversationAudioDurationFormatter.kt @@ -0,0 +1,16 @@ +package com.android.messaging.ui.conversation.v2.audio + +import java.util.Locale + +internal fun formatConversationAudioDuration(durationMillis: Long): String { + val totalSeconds = durationMillis / 1_000L + val minutes = totalSeconds / 60L + val seconds = totalSeconds % 60L + + return String.format( + Locale.getDefault(), + "%02d:%02d", + minutes, + seconds, + ) +} diff --git a/src/com/android/messaging/ui/conversation/v2/audio/delegate/ConversationAudioRecordingDelegate.kt b/src/com/android/messaging/ui/conversation/v2/audio/delegate/ConversationAudioRecordingDelegate.kt new file mode 100644 index 00000000..45980aa6 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/v2/audio/delegate/ConversationAudioRecordingDelegate.kt @@ -0,0 +1,209 @@ +package com.android.messaging.ui.conversation.v2.audio.delegate + +import android.net.Uri +import android.os.SystemClock +import com.android.messaging.data.conversation.model.draft.ConversationDraftAttachment +import com.android.messaging.data.conversation.repository.ConversationSubscriptionsRepository +import com.android.messaging.di.core.DefaultDispatcher +import com.android.messaging.ui.conversation.v2.audio.model.ConversationAudioRecordingUiState +import com.android.messaging.ui.conversation.v2.common.ConversationScreenDelegate +import com.android.messaging.ui.conversation.v2.composer.delegate.ConversationDraftDelegate +import com.android.messaging.ui.conversation.v2.mediapicker.repository.ConversationAttachmentRepository +import com.android.messaging.ui.mediapicker.LevelTrackingMediaRecorder +import com.android.messaging.util.ContentType +import com.android.messaging.util.LogUtil +import javax.inject.Inject +import kotlin.time.Duration.Companion.milliseconds +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch + +internal interface ConversationAudioRecordingDelegate : + ConversationScreenDelegate { + + fun startRecording(selfParticipantId: String) + + fun finishRecording() + + fun cancelRecording() + + fun onScreenCleared() +} + +internal class ConversationAudioRecordingDelegateImpl @Inject constructor( + private val conversationAttachmentRepository: ConversationAttachmentRepository, + private val conversationSubscriptionsRepository: ConversationSubscriptionsRepository, + private val conversationDraftDelegate: ConversationDraftDelegate, + @param:DefaultDispatcher + private val defaultDispatcher: CoroutineDispatcher, +) : ConversationAudioRecordingDelegate { + + private val _state = MutableStateFlow(ConversationAudioRecordingUiState()) + override val state = _state.asStateFlow() + + private var boundScope: CoroutineScope? = null + private var durationJob: Job? = null + private var finishRecordingJob: Job? = null + private var mediaRecorder: LevelTrackingMediaRecorder? = null + private var recordingStartedAtMillis: Long? = null + + override fun bind( + scope: CoroutineScope, + conversationIdFlow: StateFlow, + ) { + if (boundScope != null) { + return + } + + boundScope = scope + scope.launch(defaultDispatcher) { + conversationIdFlow.collect { + cancelRecording() + } + } + } + + override fun startRecording(selfParticipantId: String) { + if (state.value.isRecording) { + return + } + + boundScope?.launch(defaultDispatcher) { + val resolvedMediaRecorder = LevelTrackingMediaRecorder() + val maxMessageSize = conversationSubscriptionsRepository + .resolveMaxMessageSize(selfParticipantId = selfParticipantId) + .first() + val didStartRecording = resolvedMediaRecorder.startRecording( + null, + null, + maxMessageSize, + ) + + if (!didStartRecording) { + return@launch + } + + mediaRecorder = resolvedMediaRecorder + recordingStartedAtMillis = SystemClock.elapsedRealtime() + _state.value = ConversationAudioRecordingUiState( + isRecording = true, + ) + bindDurationTicker(scope = this) + } + } + + override fun finishRecording() { + if (!state.value.isRecording) { + return + } + + finishRecordingJob?.cancel() + + finishRecordingJob = boundScope?.launch(defaultDispatcher) { + val recordedDurationMillis = when (val startedAtMillis = recordingStartedAtMillis) { + null -> 0L + else -> SystemClock.elapsedRealtime() - startedAtMillis + } + + if (recordedDurationMillis.milliseconds < audioRecordMinimumDurationMillis) { + deleteStoppedRecording(stopRecording()) + resetRecordingState() + return@launch + } + + delay(audioRecordEndingBufferMillis) + + val recordedAttachment = stopRecording()?.let { outputUri -> + ConversationDraftAttachment( + contentType = ContentType.AUDIO_3GPP, + contentUri = outputUri.toString(), + ) + } + + recordedAttachment?.let { attachment -> + conversationDraftDelegate.addAttachments( + attachments = listOf(attachment), + ) + } + + resetRecordingState() + } + } + + override fun cancelRecording() { + if (!state.value.isRecording) { + return + } + + boundScope?.launch(defaultDispatcher) { + finishRecordingJob?.cancel() + deleteStoppedRecording(stopRecording()) + resetRecordingState() + } + } + + override fun onScreenCleared() { + cancelRecording() + } + + private fun bindDurationTicker(scope: CoroutineScope) { + durationJob?.cancel() + durationJob = scope.launch(defaultDispatcher) { + while (state.value.isRecording) { + val resolvedStartMillis = recordingStartedAtMillis ?: break + _state.value = ConversationAudioRecordingUiState( + isRecording = true, + durationMillis = SystemClock.elapsedRealtime() - resolvedStartMillis, + ) + delay(durationTickIntervalMillis) + } + } + } + + private fun stopRecording(): Uri? { + val resolvedMediaRecorder = mediaRecorder ?: return null + + return try { + resolvedMediaRecorder.stopRecording() + } catch (throwable: Throwable) { + LogUtil.w(TAG, "Failed to stop audio recording", throwable) + null + } finally { + mediaRecorder = null + } + } + + private suspend fun deleteStoppedRecording(outputUri: Uri?) { + outputUri ?: return + + conversationAttachmentRepository + .deleteTemporaryAttachment( + contentUri = outputUri.toString(), + ) + .collect() + } + + private fun resetRecordingState() { + finishRecordingJob = null + durationJob?.cancel() + durationJob = null + mediaRecorder = null + recordingStartedAtMillis = null + _state.value = ConversationAudioRecordingUiState() + } + + private companion object { + private const val TAG = "ConversationAudioRecording" + + private val audioRecordEndingBufferMillis = 500L.milliseconds + private val audioRecordMinimumDurationMillis = 300L.milliseconds + private val durationTickIntervalMillis = 200L.milliseconds + } +} diff --git a/src/com/android/messaging/ui/conversation/v2/audio/model/ConversationAudioRecordingUiState.kt b/src/com/android/messaging/ui/conversation/v2/audio/model/ConversationAudioRecordingUiState.kt new file mode 100644 index 00000000..86726bc4 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/v2/audio/model/ConversationAudioRecordingUiState.kt @@ -0,0 +1,9 @@ +package com.android.messaging.ui.conversation.v2.audio.model + +import androidx.compose.runtime.Immutable + +@Immutable +internal data class ConversationAudioRecordingUiState( + val isRecording: Boolean = false, + val durationMillis: Long = 0L, +) diff --git a/src/com/android/messaging/ui/conversation/v2/composer/mapper/ConversationComposerAttachmentUiModelMapper.kt b/src/com/android/messaging/ui/conversation/v2/composer/mapper/ConversationComposerAttachmentUiModelMapper.kt index f32227f2..b574b94d 100644 --- a/src/com/android/messaging/ui/conversation/v2/composer/mapper/ConversationComposerAttachmentUiModelMapper.kt +++ b/src/com/android/messaging/ui/conversation/v2/composer/mapper/ConversationComposerAttachmentUiModelMapper.kt @@ -51,6 +51,7 @@ internal class ConversationComposerAttachmentUiModelMapperImpl @Inject construct key = attachment.contentUri, contentType = attachment.contentType, contentUri = attachment.contentUri, + durationMillis = attachment.durationMillis ?: 0L, ) } diff --git a/src/com/android/messaging/ui/conversation/v2/composer/mapper/ConversationComposerUiStateMapper.kt b/src/com/android/messaging/ui/conversation/v2/composer/mapper/ConversationComposerUiStateMapper.kt index e5404e3c..0467f2e0 100644 --- a/src/com/android/messaging/ui/conversation/v2/composer/mapper/ConversationComposerUiStateMapper.kt +++ b/src/com/android/messaging/ui/conversation/v2/composer/mapper/ConversationComposerUiStateMapper.kt @@ -2,6 +2,7 @@ package com.android.messaging.ui.conversation.v2.composer.mapper import com.android.messaging.data.conversation.model.metadata.ConversationComposerAvailability import com.android.messaging.data.conversation.model.metadata.ConversationSubscription +import com.android.messaging.ui.conversation.v2.audio.model.ConversationAudioRecordingUiState import com.android.messaging.ui.conversation.v2.composer.model.ComposerAttachmentUiModel import com.android.messaging.ui.conversation.v2.composer.model.ConversationComposerUiState import com.android.messaging.ui.conversation.v2.composer.model.ConversationDraftState @@ -11,6 +12,7 @@ import kotlinx.collections.immutable.ImmutableList internal interface ConversationComposerUiStateMapper { fun map( + audioRecording: ConversationAudioRecordingUiState, draftState: ConversationDraftState, attachments: ImmutableList, composerAvailability: ConversationComposerAvailability, @@ -22,6 +24,7 @@ internal class ConversationComposerUiStateMapperImpl @Inject constructor() : ConversationComposerUiStateMapper { override fun map( + audioRecording: ConversationAudioRecordingUiState, draftState: ConversationDraftState, attachments: ImmutableList, composerAvailability: ConversationComposerAvailability, @@ -35,6 +38,11 @@ internal class ConversationComposerUiStateMapperImpl @Inject constructor() : !draft.isSending val isMessageFieldEnabled = composerAvailability.isMessageFieldEnabled + val shouldShowRecordAction = !hasWorkingDraft && !audioRecording.isRecording + val isRecordActionEnabled = composerAvailability.isSendAvailable && + !draft.isCheckingDraft && + !draft.isSending && + draftState.pendingAttachments.isEmpty() val isSendEnabled = composerAvailability.isSendAvailable && hasWorkingDraft && @@ -43,6 +51,7 @@ internal class ConversationComposerUiStateMapperImpl @Inject constructor() : draftState.pendingAttachments.isEmpty() return ConversationComposerUiState( + audioRecording = audioRecording, attachments = attachments, messageText = draft.messageText, subjectText = draft.subjectText, @@ -53,7 +62,9 @@ internal class ConversationComposerUiStateMapperImpl @Inject constructor() : ), isMessageFieldEnabled = isMessageFieldEnabled, isAttachmentActionEnabled = isAttachmentActionEnabled, + isRecordActionEnabled = isRecordActionEnabled, isSendEnabled = isSendEnabled, + shouldShowRecordAction = shouldShowRecordAction, hasWorkingDraft = hasWorkingDraft, isMms = draft.isMms, attachmentCount = draft.attachments.size, diff --git a/src/com/android/messaging/ui/conversation/v2/composer/model/ComposerAttachmentUiModel.kt b/src/com/android/messaging/ui/conversation/v2/composer/model/ComposerAttachmentUiModel.kt index 87f7dcae..fdffc470 100644 --- a/src/com/android/messaging/ui/conversation/v2/composer/model/ComposerAttachmentUiModel.kt +++ b/src/com/android/messaging/ui/conversation/v2/composer/model/ComposerAttachmentUiModel.kt @@ -52,6 +52,7 @@ internal sealed interface ComposerAttachmentUiModel { override val key: String, override val contentType: String, override val contentUri: String, + val durationMillis: Long, ) : Resolved @Immutable diff --git a/src/com/android/messaging/ui/conversation/v2/composer/model/ConversationComposerUiState.kt b/src/com/android/messaging/ui/conversation/v2/composer/model/ConversationComposerUiState.kt index 3af7d00d..72ac38b5 100644 --- a/src/com/android/messaging/ui/conversation/v2/composer/model/ConversationComposerUiState.kt +++ b/src/com/android/messaging/ui/conversation/v2/composer/model/ConversationComposerUiState.kt @@ -2,11 +2,13 @@ package com.android.messaging.ui.conversation.v2.composer.model import androidx.compose.runtime.Immutable import com.android.messaging.data.conversation.model.metadata.ConversationComposerDisabledReason +import com.android.messaging.ui.conversation.v2.audio.model.ConversationAudioRecordingUiState import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf @Immutable internal data class ConversationComposerUiState( + val audioRecording: ConversationAudioRecordingUiState = ConversationAudioRecordingUiState(), val attachments: ImmutableList = persistentListOf(), val messageText: String = "", val subjectText: String = "", @@ -14,7 +16,9 @@ internal data class ConversationComposerUiState( val simSelector: ConversationSimSelectorUiState = ConversationSimSelectorUiState(), val isMessageFieldEnabled: Boolean = false, val isAttachmentActionEnabled: Boolean = false, + val isRecordActionEnabled: Boolean = false, val isSendEnabled: Boolean = false, + val shouldShowRecordAction: Boolean = false, val hasWorkingDraft: Boolean = false, val isMms: Boolean = false, val attachmentCount: Int = 0, diff --git a/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationAttachmentPreview.kt b/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationAttachmentPreview.kt index cc2ffbfa..21637d30 100644 --- a/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationAttachmentPreview.kt +++ b/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationAttachmentPreview.kt @@ -6,10 +6,14 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape @@ -23,6 +27,7 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -33,6 +38,7 @@ import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import com.android.messaging.R import com.android.messaging.ui.conversation.v2.CONVERSATION_ATTACHMENT_PREVIEW_LIST_TEST_TAG +import com.android.messaging.ui.conversation.v2.audio.formatConversationAudioDuration import com.android.messaging.ui.conversation.v2.composer.model.ComposerAttachmentUiModel import com.android.messaging.ui.conversation.v2.conversationAttachmentPreviewItemTestTag import com.android.messaging.ui.conversation.v2.conversationAttachmentPreviewRemoveButtonTestTag @@ -44,6 +50,8 @@ import kotlinx.collections.immutable.ImmutableList private val ATTACHMENT_PREVIEW_CORNER_RADIUS = 20.dp private val ATTACHMENT_PREVIEW_CARD_HEIGHT = 88.dp private val ATTACHMENT_PREVIEW_CARD_WIDTH = 220.dp +private val ATTACHMENT_PREVIEW_AUDIO_REMOVE_BUTTON_MARGIN = 4.dp +private val ATTACHMENT_PREVIEW_AUDIO_REMOVE_BUTTON_SIZE = 24.dp private const val ATTACHMENT_PREVIEW_SIZE_PX = 256 @Composable @@ -151,7 +159,15 @@ private fun ResolvedAttachmentPreviewItem( ) } - is ComposerAttachmentUiModel.Resolved.Audio, + is ComposerAttachmentUiModel.Resolved.Audio -> { + ConversationAudioAttachmentPreviewItem( + attachmentKey = attachmentKey, + durationMillis = attachment.durationMillis, + onAttachmentClick = onAttachmentClick, + onRemoveClick = onRemoveClick, + ) + } + is ComposerAttachmentUiModel.Resolved.File, is ComposerAttachmentUiModel.Resolved.VisualMedia.Image, is ComposerAttachmentUiModel.Resolved.VisualMedia.Video, @@ -216,6 +232,65 @@ private fun VideoAttachmentOverlay() { } } +@Composable +private fun ConversationAudioAttachmentPreviewItem( + attachmentKey: String, + durationMillis: Long, + onAttachmentClick: () -> Unit, + onRemoveClick: () -> Unit, +) { + AttachmentPreviewItemContainer( + modifier = Modifier + .height(height = ATTACHMENT_PREVIEW_CARD_HEIGHT) + .wrapContentWidth(), + attachmentKey = attachmentKey, + onClick = onAttachmentClick, + ) { + Row( + modifier = Modifier + .wrapContentWidth() + .height(height = ATTACHMENT_PREVIEW_CARD_HEIGHT) + .padding(horizontal = 16.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Rounded.PlayArrow, + contentDescription = null, + modifier = Modifier + .size(40.dp) + .clip(shape = RoundedCornerShape(size = 20.dp)) + .background(color = MaterialTheme.colorScheme.primary) + .padding(8.dp), + tint = MaterialTheme.colorScheme.onPrimary, + ) + + Text( + modifier = Modifier.wrapContentWidth(), + text = formatConversationAudioDuration(durationMillis = durationMillis), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + ) + + Box( + modifier = Modifier + .width( + width = ATTACHMENT_PREVIEW_AUDIO_REMOVE_BUTTON_SIZE + + ATTACHMENT_PREVIEW_AUDIO_REMOVE_BUTTON_MARGIN, + ) + .fillMaxSize(), + contentAlignment = Alignment.TopCenter, + ) { + InlineAudioRemoveAttachmentButton( + attachmentKey = attachmentKey, + onClick = onRemoveClick, + ) + } + } + } +} + @Composable private fun ConversationVCardAttachmentPreviewItem( attachmentKey: String, @@ -309,3 +384,32 @@ private fun BoxScope.RemoveAttachmentButton( ) } } + +@Composable +private fun InlineAudioRemoveAttachmentButton( + attachmentKey: String, + onClick: () -> Unit, +) { + FilledIconButton( + modifier = Modifier + .size(size = ATTACHMENT_PREVIEW_AUDIO_REMOVE_BUTTON_SIZE) + .testTag( + conversationAttachmentPreviewRemoveButtonTestTag( + attachmentKey = attachmentKey, + ), + ), + onClick = onClick, + colors = IconButtonDefaults.filledIconButtonColors( + containerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.9f), + contentColor = MaterialTheme.colorScheme.onSurface, + ), + ) { + Icon( + imageVector = Icons.Rounded.Close, + contentDescription = pluralStringResource( + id = R.plurals.attachment_preview_close_content_description, + count = 1, + ), + ) + } +} diff --git a/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationAudioRecordingBar.kt b/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationAudioRecordingBar.kt new file mode 100644 index 00000000..efd2d5df --- /dev/null +++ b/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationAudioRecordingBar.kt @@ -0,0 +1,291 @@ +package com.android.messaging.ui.conversation.v2.composer.ui + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.animation.expandHorizontally +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkHorizontally +import androidx.compose.animation.slideInHorizontally +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.ArrowBack +import androidx.compose.material.icons.rounded.DeleteOutline +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.lerp +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.android.messaging.R +import com.android.messaging.ui.conversation.v2.CONVERSATION_AUDIO_RECORDING_BAR_TEST_TAG +import com.android.messaging.ui.conversation.v2.CONVERSATION_AUDIO_RECORDING_CANCEL_BUTTON_TEST_TAG +import com.android.messaging.ui.conversation.v2.audio.formatConversationAudioDuration + +private const val AUDIO_RECORDING_COLOR_ANIMATION_THRESHOLD = 0.7f + +@Composable +internal fun ConversationAudioRecordingBar( + modifier: Modifier = Modifier, + durationMillis: Long, + cancelProgress: Float, + isCancellationArmed: Boolean, +) { + val visualState = animateAudioRecordingBarVisualState( + cancelProgress = cancelProgress, + isCancellationArmed = isCancellationArmed, + ) + + Row( + modifier = modifier + .fillMaxWidth() + .height(height = 56.dp) + .background( + color = MaterialTheme.colorScheme.surfaceContainerHigh, + shape = RoundedCornerShape(size = 28.dp), + ) + .padding( + horizontal = 12.dp, + ) + .testTag(CONVERSATION_AUDIO_RECORDING_BAR_TEST_TAG), + verticalAlignment = Alignment.CenterVertically, + ) { + AudioRecordingDeleteIcon( + isVisible = isCancellationArmed, + tint = visualState.deleteIconTint, + ) + + Spacer(modifier = Modifier.width(width = 4.dp)) + + AudioRecordingDurationLabel( + durationMillis = durationMillis, + contentColor = visualState.contentColor, + ) + + AudioRecordingCancelHint( + modifier = Modifier + .weight(weight = 1f) + .padding(end = 8.dp), + contentColor = visualState.contentColor, + hintAlpha = visualState.hintAlpha, + ) + } +} + +@Composable +private fun animateAudioRecordingBarVisualState( + cancelProgress: Float, + isCancellationArmed: Boolean, +): AudioRecordingBarVisualState { + val visualProgress = when { + isCancellationArmed -> 1f + else -> { + (cancelProgress / AUDIO_RECORDING_COLOR_ANIMATION_THRESHOLD) + .coerceIn(minimumValue = 0f, maximumValue = 1f) + } + } + + val contentColor = lerp( + start = MaterialTheme.colorScheme.onSurfaceVariant, + stop = when { + isCancellationArmed -> MaterialTheme.colorScheme.error + else -> MaterialTheme.colorScheme.onSurface + }, + fraction = visualProgress, + ) + + val deleteIconTint = animateColorAsRecordingState( + isCancellationArmed = isCancellationArmed, + visualProgress = visualProgress, + ) + + val hintAlpha = animateFloatAsState( + targetValue = 1f - (visualProgress * 0.45f), + animationSpec = tween(durationMillis = 180), + label = "conversation_audio_hint_alpha", + ).value + + return AudioRecordingBarVisualState( + contentColor = contentColor, + deleteIconTint = deleteIconTint, + hintAlpha = hintAlpha, + ) +} + +@Composable +private fun AudioRecordingDeleteIcon( + isVisible: Boolean, + tint: Color, +) { + AnimatedVisibility( + visible = isVisible, + enter = fadeIn(animationSpec = tween(durationMillis = 220)) + + expandHorizontally( + animationSpec = tween(durationMillis = 260), + expandFrom = Alignment.Start, + ), + exit = fadeOut(animationSpec = tween(durationMillis = 140)) + + shrinkHorizontally( + animationSpec = tween(durationMillis = 180), + shrinkTowards = Alignment.Start, + ), + ) { + Icon( + modifier = Modifier.testTag(CONVERSATION_AUDIO_RECORDING_CANCEL_BUTTON_TEST_TAG), + imageVector = Icons.Rounded.DeleteOutline, + contentDescription = null, + tint = tint, + ) + } +} + +@Composable +private fun AudioRecordingDurationLabel( + durationMillis: Long, + contentColor: Color, +) { + AnimatedVisibility( + visible = true, + enter = fadeIn(animationSpec = tween(durationMillis = 140)) + + slideInHorizontally( + animationSpec = tween(durationMillis = 200), + initialOffsetX = { fullWidth -> + -(fullWidth / 6) + }, + ), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + RecordingIndicatorDot() + + Text( + modifier = Modifier.padding( + start = 8.dp, + end = 12.dp, + ), + text = formatConversationAudioDuration(durationMillis = durationMillis), + style = MaterialTheme.typography.titleMedium, + color = contentColor, + ) + } + } +} + +@Composable +private fun AudioRecordingCancelHint( + modifier: Modifier = Modifier, + contentColor: Color, + hintAlpha: Float, +) { + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.AutoMirrored.Rounded.ArrowBack, + contentDescription = null, + tint = contentColor.copy(alpha = hintAlpha), + ) + + Text( + modifier = Modifier.padding(start = 4.dp), + text = stringResource(id = R.string.conversation_audio_recording_slide_to_cancel), + style = MaterialTheme.typography.titleMedium, + color = contentColor.copy(alpha = hintAlpha), + ) + } +} + +@Composable +private fun animateColorAsRecordingState( + isCancellationArmed: Boolean, + visualProgress: Float, +): Color { + return animateColorAsState( + targetValue = lerp( + start = MaterialTheme.colorScheme.onSurfaceVariant, + stop = when { + isCancellationArmed -> MaterialTheme.colorScheme.error + else -> MaterialTheme.colorScheme.onSurface + }, + fraction = visualProgress, + ), + animationSpec = tween(durationMillis = 180), + label = "conversation_audio_delete_icon_color", + ).value +} + +@Composable +private fun RecordingIndicatorDot() { + val pulseTransition = rememberInfiniteTransition( + label = "conversation_audio_recording_dot", + ) + + val dotScale = pulseTransition.animateFloat( + initialValue = 0.9f, + targetValue = 1.2f, + animationSpec = infiniteRepeatable( + animation = tween( + durationMillis = 900, + easing = FastOutSlowInEasing, + ), + repeatMode = RepeatMode.Reverse, + ), + label = "conversation_audio_recording_dot_scale", + ).value + val dotAlpha = pulseTransition.animateFloat( + initialValue = 0.6f, + targetValue = 1f, + animationSpec = infiniteRepeatable( + animation = tween( + durationMillis = 900, + easing = FastOutSlowInEasing, + ), + repeatMode = RepeatMode.Reverse, + ), + label = "conversation_audio_recording_dot_alpha", + ).value + + Box( + modifier = Modifier + .size(size = 10.dp) + .graphicsLayer { + scaleX = dotScale + scaleY = dotScale + alpha = dotAlpha + } + .background( + color = MaterialTheme.colorScheme.error, + shape = RoundedCornerShape(size = 100.dp), + ), + ) +} + +private data class AudioRecordingBarVisualState( + val contentColor: Color, + val deleteIconTint: Color, + val hintAlpha: Float, +) diff --git a/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationComposeBar.kt b/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationComposeBar.kt index 3f2fae3b..4d333d4a 100644 --- a/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationComposeBar.kt +++ b/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationComposeBar.kt @@ -1,5 +1,13 @@ package com.android.messaging.ui.conversation.v2.composer.ui +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.ContentTransform +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.animation.togetherWith import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -24,7 +32,9 @@ import androidx.compose.material3.TextField import androidx.compose.material3.TextFieldColors import androidx.compose.material3.TextFieldDefaults import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable @@ -35,6 +45,7 @@ import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color 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 @@ -49,24 +60,43 @@ import com.android.messaging.ui.conversation.v2.CONVERSATION_COMPOSE_BAR_TEST_TA import com.android.messaging.ui.conversation.v2.CONVERSATION_SEND_BUTTON_SHAPE_CIRCLE import com.android.messaging.ui.conversation.v2.CONVERSATION_SEND_BUTTON_TEST_TAG import com.android.messaging.ui.conversation.v2.CONVERSATION_TEXT_FIELD_TEST_TAG +import com.android.messaging.ui.conversation.v2.audio.model.ConversationAudioRecordingUiState import com.android.messaging.ui.conversation.v2.conversationShape import com.android.messaging.ui.core.AppTheme +private val AUDIO_RECORD_CANCEL_THRESHOLD = 96.dp + @Composable internal fun ConversationComposeBar( modifier: Modifier = Modifier, + audioRecording: ConversationAudioRecordingUiState, messageText: String, isMessageFieldEnabled: Boolean, isAttachmentActionEnabled: Boolean, + isRecordActionEnabled: Boolean, isSendActionEnabled: Boolean, + shouldShowRecordAction: Boolean, messageFieldFocusRequester: FocusRequester? = null, onContactAttachClick: () -> Unit, onMediaPickerClick: () -> Unit, onMessageTextChange: (String) -> Unit, + onAudioRecordingStartRequest: () -> Unit, + onAudioRecordingFinish: () -> Unit, + onAudioRecordingCancel: () -> Unit, onSendClick: () -> Unit, ) { val presentation = rememberConversationComposeBarPresentation() + var recordingDragDistancePx by remember { + mutableFloatStateOf(value = 0f) + } + + LaunchedEffect(audioRecording.isRecording) { + if (!audioRecording.isRecording) { + recordingDragDistancePx = 0f + } + } + Box( modifier = modifier .fillMaxWidth() @@ -74,16 +104,34 @@ internal fun ConversationComposeBar( .navigationBarsPadding() .testTag(CONVERSATION_COMPOSE_BAR_TEST_TAG), ) { - ConversationComposeTextField( + ConversationComposeInputContent( + audioRecording = audioRecording, messageText = messageText, isMessageFieldEnabled = isMessageFieldEnabled, isAttachmentActionEnabled = isAttachmentActionEnabled, + isRecordActionEnabled = isRecordActionEnabled, isSendActionEnabled = isSendActionEnabled, + shouldShowRecordAction = shouldShowRecordAction, + recordingDragDistancePx = recordingDragDistancePx, messageFieldFocusRequester = messageFieldFocusRequester, presentation = presentation, onContactAttachClick = onContactAttachClick, onMediaPickerClick = onMediaPickerClick, onMessageTextChange = onMessageTextChange, + onAudioRecordingStartRequest = { + recordingDragDistancePx = 0f + onAudioRecordingStartRequest() + }, + onAudioRecordingDrag = { dragDistancePx -> + recordingDragDistancePx = dragDistancePx + }, + onAudioRecordingFinish = { shouldCancelRecording -> + recordingDragDistancePx = 0f + when { + shouldCancelRecording -> onAudioRecordingCancel() + else -> onAudioRecordingFinish() + } + }, onSendClick = onSendClick, ) } @@ -126,18 +174,34 @@ private fun conversationComposeBarTextFieldColors(): TextFieldColors { } @Composable -private fun ConversationComposeTextField( +private fun ConversationComposeInputContent( + audioRecording: ConversationAudioRecordingUiState, messageText: String, isMessageFieldEnabled: Boolean, isAttachmentActionEnabled: Boolean, + isRecordActionEnabled: Boolean, isSendActionEnabled: Boolean, + shouldShowRecordAction: Boolean, + recordingDragDistancePx: Float, messageFieldFocusRequester: FocusRequester?, presentation: ConversationComposeBarPresentation, onContactAttachClick: () -> Unit, onMediaPickerClick: () -> Unit, onMessageTextChange: (String) -> Unit, + onAudioRecordingStartRequest: () -> Unit, + onAudioRecordingDrag: (Float) -> Unit, + onAudioRecordingFinish: (Boolean) -> Unit, onSendClick: () -> Unit, ) { + val cancelThresholdPx = with(LocalDensity.current) { + AUDIO_RECORD_CANCEL_THRESHOLD.toPx() + } + val cancelProgress = (recordingDragDistancePx / cancelThresholdPx) + .coerceIn(minimumValue = 0f, maximumValue = 1f) + + val isCancellationArmed = cancelProgress >= 1f + val isRecordMode = shouldShowRecordAction || audioRecording.isRecording + Row( modifier = Modifier .fillMaxWidth() @@ -150,36 +214,39 @@ private fun ConversationComposeTextField( ), verticalAlignment = Alignment.Bottom, ) { - TextField( + AnimatedContent( modifier = Modifier - .weight(weight = 1f) - .then( - when (messageFieldFocusRequester) { - null -> Modifier - else -> Modifier.focusRequester(messageFieldFocusRequester) - }, - ) - .testTag(CONVERSATION_TEXT_FIELD_TEST_TAG) - .heightIn(min = 56.dp), - value = messageText, - onValueChange = onMessageTextChange, - enabled = isMessageFieldEnabled, - shape = presentation.fieldShape, - colors = presentation.fieldColors, - placeholder = { - ConversationComposePlaceholder() - }, - leadingIcon = { - ConversationComposeAttachmentMenu( - modifier = Modifier.testTag(CONVERSATION_ATTACHMENT_BUTTON_TEST_TAG), - enabled = isAttachmentActionEnabled, - onContactAttachClick = onContactAttachClick, - onMediaPickerClick = onMediaPickerClick, - ) + .weight(weight = 1f), + targetState = audioRecording.isRecording, + transitionSpec = { + contentSwapTransition() }, - minLines = 1, - maxLines = 4, - ) + label = "conversation_compose_content", + ) { isRecording -> + when { + isRecording -> { + ConversationAudioRecordingBar( + durationMillis = audioRecording.durationMillis, + cancelProgress = cancelProgress, + isCancellationArmed = isCancellationArmed, + ) + } + + else -> { + ConversationComposeMessageField( + modifier = Modifier, + value = messageText, + onValueChange = onMessageTextChange, + enabled = isMessageFieldEnabled, + messageFieldFocusRequester = messageFieldFocusRequester, + presentation = presentation, + isAttachmentActionEnabled = isAttachmentActionEnabled, + onContactAttachClick = onContactAttachClick, + onMediaPickerClick = onMediaPickerClick, + ) + } + } + } ConversationComposeSendAction( modifier = Modifier @@ -187,12 +254,64 @@ private fun ConversationComposeTextField( .semantics { conversationShape = CONVERSATION_SEND_BUTTON_SHAPE_CIRCLE }, - enabled = isSendActionEnabled, + enabled = when { + isRecordMode -> isRecordActionEnabled + else -> isSendActionEnabled + }, + mode = when { + isRecordMode -> ConversationSendActionButtonMode.Record + else -> ConversationSendActionButtonMode.Send + }, + isRecordingActive = audioRecording.isRecording, onClick = onSendClick, + onRecordGestureStart = onAudioRecordingStartRequest, + onRecordGestureMove = onAudioRecordingDrag, + onRecordGestureFinish = onAudioRecordingFinish, ) } } +@Composable +private fun ConversationComposeMessageField( + modifier: Modifier = Modifier, + value: String, + enabled: Boolean, + messageFieldFocusRequester: FocusRequester?, + presentation: ConversationComposeBarPresentation, + isAttachmentActionEnabled: Boolean, + onValueChange: (String) -> Unit, + onContactAttachClick: () -> Unit, + onMediaPickerClick: () -> Unit, +) { + val focusRequesterModifier = messageFieldFocusRequester + ?.let(Modifier::focusRequester) + ?: Modifier + + TextField( + modifier = modifier + .then(focusRequesterModifier) + .testTag(CONVERSATION_TEXT_FIELD_TEST_TAG) + .heightIn(min = 56.dp), + value = value, + onValueChange = onValueChange, + enabled = enabled, + shape = presentation.fieldShape, + colors = presentation.fieldColors, + placeholder = ::ConversationComposePlaceholder, + leadingIcon = { + ConversationComposeAttachmentMenu( + modifier = Modifier + .testTag(CONVERSATION_ATTACHMENT_BUTTON_TEST_TAG), + enabled = isAttachmentActionEnabled, + onContactAttachClick = onContactAttachClick, + onMediaPickerClick = onMediaPickerClick, + ) + }, + minLines = 1, + maxLines = 4, + ) +} + @Composable private fun ConversationComposePlaceholder() { Text( @@ -201,6 +320,26 @@ private fun ConversationComposePlaceholder() { ) } +private fun contentSwapTransition(): ContentTransform { + return ( + fadeIn(animationSpec = tween(durationMillis = 160)) + + slideInHorizontally( + animationSpec = tween(durationMillis = 220), + initialOffsetX = { fullWidth -> + fullWidth / 10 + }, + ) + ).togetherWith( + fadeOut(animationSpec = tween(durationMillis = 120)) + + slideOutHorizontally( + animationSpec = tween(durationMillis = 180), + targetOffsetX = { fullWidth -> + -(fullWidth / 12) + }, + ), + ) +} + @Composable private fun ConversationComposeAttachmentMenu( modifier: Modifier = Modifier, @@ -226,7 +365,7 @@ private fun ConversationComposeAttachmentMenu( Icon( imageVector = Icons.Rounded.AddCircleOutline, contentDescription = stringResource( - id = R.string.attachMediaButtonContentDescription + id = R.string.attachMediaButtonContentDescription, ), tint = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -282,12 +421,22 @@ private fun ConversationComposeAttachmentMenu( private fun ConversationComposeSendAction( modifier: Modifier = Modifier, enabled: Boolean, + mode: ConversationSendActionButtonMode, + isRecordingActive: Boolean, onClick: () -> Unit, + onRecordGestureStart: () -> Unit, + onRecordGestureMove: (Float) -> Unit, + onRecordGestureFinish: (Boolean) -> Unit, ) { ConversationSendActionButton( modifier = modifier, enabled = enabled, + mode = mode, + isRecordingActive = isRecordingActive, onClick = onClick, + onRecordGestureStart = onRecordGestureStart, + onRecordGestureMove = onRecordGestureMove, + onRecordGestureFinish = onRecordGestureFinish, ) } diff --git a/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationComposerSection.kt b/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationComposerSection.kt index f1e5bc7d..10c66543 100644 --- a/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationComposerSection.kt +++ b/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationComposerSection.kt @@ -4,17 +4,21 @@ import androidx.compose.foundation.layout.Column import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester +import com.android.messaging.ui.conversation.v2.audio.model.ConversationAudioRecordingUiState import com.android.messaging.ui.conversation.v2.composer.model.ComposerAttachmentUiModel import kotlinx.collections.immutable.ImmutableList @Composable internal fun ConversationComposerSection( modifier: Modifier = Modifier, + audioRecording: ConversationAudioRecordingUiState, attachments: ImmutableList, messageText: String, isMessageFieldEnabled: Boolean, isAttachmentActionEnabled: Boolean, + isRecordActionEnabled: Boolean, isSendActionEnabled: Boolean, + shouldShowRecordAction: Boolean, messageFieldFocusRequester: FocusRequester, onContactAttachClick: () -> Unit, onMediaPickerClick: () -> Unit, @@ -22,6 +26,9 @@ internal fun ConversationComposerSection( onPendingAttachmentRemove: (String) -> Unit, onResolvedAttachmentClick: (ComposerAttachmentUiModel.Resolved) -> Unit, onResolvedAttachmentRemove: (String) -> Unit, + onAudioRecordingStartRequest: () -> Unit, + onAudioRecordingFinish: () -> Unit, + onAudioRecordingCancel: () -> Unit, onSendClick: () -> Unit, ) { Column( @@ -35,14 +42,20 @@ internal fun ConversationComposerSection( ) ConversationComposeBar( + audioRecording = audioRecording, messageText = messageText, isMessageFieldEnabled = isMessageFieldEnabled, isAttachmentActionEnabled = isAttachmentActionEnabled, + isRecordActionEnabled = isRecordActionEnabled, isSendActionEnabled = isSendActionEnabled, + shouldShowRecordAction = shouldShowRecordAction, messageFieldFocusRequester = messageFieldFocusRequester, onContactAttachClick = onContactAttachClick, onMediaPickerClick = onMediaPickerClick, onMessageTextChange = onMessageTextChange, + onAudioRecordingStartRequest = onAudioRecordingStartRequest, + onAudioRecordingFinish = onAudioRecordingFinish, + onAudioRecordingCancel = onAudioRecordingCancel, onSendClick = onSendClick, ) } diff --git a/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationSendActionButton.kt b/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationSendActionButton.kt index 0725b678..766fc768 100644 --- a/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationSendActionButton.kt +++ b/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationSendActionButton.kt @@ -1,5 +1,27 @@ package com.android.messaging.ui.conversation.v2.composer.ui +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.StartOffset +import androidx.compose.animation.core.StartOffsetType +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.gestures.awaitLongPressOrCancellation +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons @@ -9,40 +31,451 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.hapticfeedback.HapticFeedbackType -import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.scale +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.AwaitPointerEventScope +import androidx.compose.ui.input.pointer.PointerId +import androidx.compose.ui.input.pointer.PointerInputChange +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.android.messaging.R +@Immutable +internal enum class ConversationSendActionButtonMode { + Send, + Record, +} + +@Immutable +private data class ConversationSendActionButtonVisualState( + val buttonScale: Float, + val containerColor: Color, + val contentColor: Color, +) + @Composable internal fun ConversationSendActionButton( modifier: Modifier = Modifier, enabled: Boolean, + mode: ConversationSendActionButtonMode, + isRecordingActive: Boolean, + onClick: () -> Unit, + onRecordGestureStart: () -> Unit, + onRecordGestureMove: (Float) -> Unit, + onRecordGestureFinish: (Boolean) -> Unit, +) { + var isRecordGestureActive by remember(mode, enabled) { + mutableStateOf(value = false) + } + + val visualState = animateConversationSendActionButtonVisualState( + isRecordingActive = isRecordingActive, + isRecordGestureActive = isRecordGestureActive, + ) + + val cancelThresholdPx = with(LocalDensity.current) { + 96.dp.toPx() + } + + val gestureModifier = Modifier.conversationSendActionButtonGesture( + mode = mode, + enabled = enabled, + cancelThresholdPx = cancelThresholdPx, + onGestureActiveChange = { isActive -> + isRecordGestureActive = isActive + }, + onRecordGestureStart = onRecordGestureStart, + onRecordGestureMove = onRecordGestureMove, + onRecordGestureFinish = onRecordGestureFinish, + ) + + ConversationSendActionButtonLayout( + modifier = modifier, + isRecordingActive = isRecordingActive, + buttonModifier = gestureModifier, + enabled = enabled, + mode = mode, + onClick = onClick, + visualState = visualState, + ) +} + +@Composable +private fun animateConversationSendActionButtonVisualState( + isRecordingActive: Boolean, + isRecordGestureActive: Boolean, +): ConversationSendActionButtonVisualState { + val pulseAnimation = rememberInfiniteTransition( + label = "conversation_send_action_pulse", + ) + + val pulseScale by pulseAnimation.animateFloat( + initialValue = 1f, + targetValue = 1.1f, + animationSpec = infiniteRepeatable( + animation = tween( + durationMillis = 1000, + easing = FastOutSlowInEasing, + ), + repeatMode = RepeatMode.Reverse, + ), + label = "conversation_send_action_pulse_scale", + ) + + val baseButtonScale by animateFloatAsState( + targetValue = when { + isRecordingActive -> 1.1f + isRecordGestureActive -> 0.95f + else -> 1f + }, + animationSpec = tween(durationMillis = 180), + label = "conversation_send_action_base_scale", + ) + + val buttonScale = when { + isRecordingActive -> baseButtonScale * pulseScale + else -> baseButtonScale + } + + val containerColor by animateColorAsState( + targetValue = when { + isRecordingActive -> MaterialTheme.colorScheme.error + else -> MaterialTheme.colorScheme.primary + }, + animationSpec = tween(durationMillis = 220), + label = "conversation_send_action_container_color", + ) + + val contentColor by animateColorAsState( + targetValue = when { + isRecordingActive -> MaterialTheme.colorScheme.onError + else -> MaterialTheme.colorScheme.onPrimary + }, + animationSpec = tween(durationMillis = 220), + label = "conversation_send_action_content_color", + ) + + return ConversationSendActionButtonVisualState( + buttonScale = buttonScale, + containerColor = containerColor, + contentColor = contentColor, + ) +} + +private fun Modifier.conversationSendActionButtonGesture( + mode: ConversationSendActionButtonMode, + enabled: Boolean, + cancelThresholdPx: Float, + onGestureActiveChange: (Boolean) -> Unit, + onRecordGestureStart: () -> Unit, + onRecordGestureMove: (Float) -> Unit, + onRecordGestureFinish: (Boolean) -> Unit, +): Modifier { + return when { + mode == ConversationSendActionButtonMode.Record && enabled -> { + then( + Modifier + .pointerInput(mode, true, cancelThresholdPx) { + awaitEachGesture { + handleRecordGesture( + cancelThresholdPx = cancelThresholdPx, + onGestureActiveChange = onGestureActiveChange, + onRecordGestureStart = onRecordGestureStart, + onRecordGestureMove = onRecordGestureMove, + onRecordGestureFinish = onRecordGestureFinish, + ) + } + }, + ) + } + + else -> this + } +} + +private suspend fun AwaitPointerEventScope.handleRecordGesture( + cancelThresholdPx: Float, + onGestureActiveChange: (Boolean) -> Unit, + onRecordGestureStart: () -> Unit, + onRecordGestureMove: (Float) -> Unit, + onRecordGestureFinish: (Boolean) -> Unit, +) { + val down = awaitFirstDown(requireUnconsumed = false) + + val longPressChange = awaitLongPressOrCancellation(pointerId = down.id) + ?: return + + onGestureActiveChange(true) + onRecordGestureStart() + + trackRecordGestureDrag( + down = down, + longPressChange = longPressChange, + cancelThresholdPx = cancelThresholdPx, + onGestureActiveChange = onGestureActiveChange, + onRecordGestureMove = onRecordGestureMove, + onRecordGestureFinish = onRecordGestureFinish, + ) +} + +private suspend fun AwaitPointerEventScope.trackRecordGestureDrag( + down: PointerInputChange, + longPressChange: PointerInputChange, + cancelThresholdPx: Float, + onGestureActiveChange: (Boolean) -> Unit, + onRecordGestureMove: (Float) -> Unit, + onRecordGestureFinish: (Boolean) -> Unit, +) { + var shouldCancel: Boolean + + longPressChange.consume() + + while (true) { + val pointerChange = awaitRecordGestureChange(pointerId = down.id) ?: break + val dragDistance = calculateRecordGestureDragDistance( + down = down, + pointerChange = pointerChange, + ) + + onRecordGestureMove(dragDistance) + shouldCancel = dragDistance >= cancelThresholdPx + pointerChange.consume() + + if (!pointerChange.pressed) { + onGestureActiveChange(false) + onRecordGestureFinish(shouldCancel) + return + } + } +} + +private suspend fun AwaitPointerEventScope.awaitRecordGestureChange( + pointerId: PointerId, +): PointerInputChange? { + return awaitPointerEvent() + .changes + .firstOrNull { change -> + change.id == pointerId + } +} + +private fun calculateRecordGestureDragDistance( + down: PointerInputChange, + pointerChange: PointerInputChange, +): Float { + return (down.position.x - pointerChange.position.x) + .coerceAtLeast(minimumValue = 0f) +} + +@Composable +private fun ConversationSendActionButtonLayout( + modifier: Modifier, + isRecordingActive: Boolean, + buttonModifier: Modifier, + enabled: Boolean, + mode: ConversationSendActionButtonMode, onClick: () -> Unit, + visualState: ConversationSendActionButtonVisualState, ) { - val hapticFeedback = LocalHapticFeedback.current + Box( + modifier = modifier.size(size = 56.dp), + ) { + ConversationSendActionButtonPulseBackdrop( + isVisible = isRecordingActive, + ) + ConversationSendActionButtonContent( + modifier = buttonModifier, + enabled = enabled, + mode = mode, + onClick = onClick, + visualState = visualState, + ) + } +} + +@Composable +private fun ConversationSendActionButtonContent( + modifier: Modifier, + enabled: Boolean, + mode: ConversationSendActionButtonMode, + onClick: () -> Unit, + visualState: ConversationSendActionButtonVisualState, +) { FilledIconButton( - modifier = modifier - .size(size = 56.dp), + modifier = Modifier + .fillMaxSize() + .scale(scale = visualState.buttonScale) + .then(modifier), onClick = { - hapticFeedback.performHapticFeedback(HapticFeedbackType.ContextClick) - onClick() + if (mode == ConversationSendActionButtonMode.Send) { + onClick() + } }, enabled = enabled, shape = CircleShape, colors = IconButtonDefaults.filledIconButtonColors( - containerColor = MaterialTheme.colorScheme.primary, - contentColor = MaterialTheme.colorScheme.onPrimary, + containerColor = visualState.containerColor, + contentColor = visualState.contentColor, disabledContainerColor = MaterialTheme.colorScheme.surfaceContainerHighest, disabledContentColor = MaterialTheme.colorScheme.onSurfaceVariant, ), ) { - Icon( - imageVector = Icons.AutoMirrored.Rounded.Send, - contentDescription = stringResource(id = R.string.sendButtonContentDescription), + ConversationSendActionButtonIcon( + mode = mode, ) } } + +@Composable +private fun ConversationSendActionButtonIcon(mode: ConversationSendActionButtonMode) { + AnimatedContent( + targetState = mode, + transitionSpec = { + ( + fadeIn(animationSpec = tween(durationMillis = 150)) + + scaleIn( + animationSpec = tween(durationMillis = 150), + initialScale = 0.88f, + ) + ).togetherWith( + fadeOut(animationSpec = tween(durationMillis = 120)) + + scaleOut( + animationSpec = tween(durationMillis = 120), + targetScale = 1.08f, + ), + ) + }, + label = "conversation_send_action_icon", + ) { currentMode -> + when (currentMode) { + ConversationSendActionButtonMode.Send -> { + Icon( + imageVector = Icons.AutoMirrored.Rounded.Send, + contentDescription = stringResource( + id = R.string.sendButtonContentDescription, + ), + ) + } + + ConversationSendActionButtonMode.Record -> { + Icon( + painter = painterResource(id = R.drawable.ic_mp_audio_mic), + contentDescription = stringResource( + id = R.string.audio_record_view_content_description, + ), + ) + } + } + } +} + +@Composable +private fun ConversationSendActionButtonPulseBackdrop( + isVisible: Boolean, +) { + if (!isVisible) { + return + } + + val pulseTransition = rememberInfiniteTransition( + label = "conversation_send_action_backdrop", + ) + + val outerPulseScale by pulseTransition.animateFloat( + initialValue = 1f, + targetValue = 2.9f, + animationSpec = infiniteRepeatable( + animation = tween( + durationMillis = 2100, + easing = FastOutSlowInEasing, + ), + repeatMode = RepeatMode.Restart, + ), + label = "conversation_send_action_outer_pulse_scale", + ) + + val outerPulseAlpha by pulseTransition.animateFloat( + initialValue = 0.2f, + targetValue = 0f, + animationSpec = infiniteRepeatable( + animation = tween( + durationMillis = 2100, + easing = FastOutSlowInEasing, + ), + repeatMode = RepeatMode.Restart, + ), + label = "conversation_send_action_outer_pulse_alpha", + ) + + val innerPulseScale by pulseTransition.animateFloat( + initialValue = 1f, + targetValue = 2.5f, + animationSpec = infiniteRepeatable( + animation = tween( + durationMillis = 2100, + easing = FastOutSlowInEasing, + ), + repeatMode = RepeatMode.Restart, + initialStartOffset = StartOffset( + offsetMillis = 1050, + offsetType = StartOffsetType.FastForward, + ), + ), + label = "conversation_send_action_inner_pulse_scale", + ) + + val innerPulseAlpha by pulseTransition.animateFloat( + initialValue = 0.15f, + targetValue = 0f, + animationSpec = infiniteRepeatable( + animation = tween( + durationMillis = 2100, + easing = FastOutSlowInEasing, + ), + repeatMode = RepeatMode.Restart, + initialStartOffset = StartOffset( + offsetMillis = 1050, + offsetType = StartOffsetType.FastForward, + ), + ), + label = "conversation_send_action_inner_pulse_alpha", + ) + + ConversationSendActionPulseCircle( + scale = outerPulseScale, + alpha = outerPulseAlpha, + ) + + ConversationSendActionPulseCircle( + scale = innerPulseScale, + alpha = innerPulseAlpha, + ) +} + +@Composable +private fun ConversationSendActionPulseCircle( + scale: Float, + alpha: Float, +) { + Box( + modifier = Modifier + .fillMaxSize() + .scale(scale = scale) + .alpha(alpha = alpha) + .background( + color = MaterialTheme.colorScheme.error, + shape = CircleShape, + ), + ) +} diff --git a/src/com/android/messaging/ui/conversation/v2/mediapicker/component/capture/ConversationMediaCaptureControls.kt b/src/com/android/messaging/ui/conversation/v2/mediapicker/component/capture/ConversationMediaCaptureControls.kt index 61204da0..d4c1d249 100644 --- a/src/com/android/messaging/ui/conversation/v2/mediapicker/component/capture/ConversationMediaCaptureControls.kt +++ b/src/com/android/messaging/ui/conversation/v2/mediapicker/component/capture/ConversationMediaCaptureControls.kt @@ -27,10 +27,10 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.android.messaging.R +import com.android.messaging.ui.conversation.v2.audio.formatConversationAudioDuration import com.android.messaging.ui.conversation.v2.mediapicker.ConversationCaptureMode import com.android.messaging.ui.conversation.v2.mediapicker.camera.ConversationPhotoFlashMode import com.android.messaging.ui.conversation.v2.mediapicker.component.PickerOverlayIconButton -import java.util.Locale @Composable internal fun ConversationMediaCaptureTopBar( @@ -221,17 +221,9 @@ private fun ConversationMediaRecordingTimerPill( Text( modifier = Modifier .padding(horizontal = 14.dp, vertical = 8.dp), - text = formatRecordingDuration(durationMillis = durationMillis), + text = formatConversationAudioDuration(durationMillis = durationMillis), color = MaterialTheme.colorScheme.onErrorContainer, style = MaterialTheme.typography.labelLarge, ) } } - -private fun formatRecordingDuration(durationMillis: Long): String { - val totalSeconds = durationMillis / 1_000L - val minutes = totalSeconds / 60L - val seconds = totalSeconds % 60L - - return String.format(Locale.getDefault(), "%02d:%02d", minutes, seconds) -} diff --git a/src/com/android/messaging/ui/conversation/v2/mediapicker/component/review/ConversationMediaPickerReview.kt b/src/com/android/messaging/ui/conversation/v2/mediapicker/component/review/ConversationMediaPickerReview.kt index 49aa4de9..48277926 100644 --- a/src/com/android/messaging/ui/conversation/v2/mediapicker/component/review/ConversationMediaPickerReview.kt +++ b/src/com/android/messaging/ui/conversation/v2/mediapicker/component/review/ConversationMediaPickerReview.kt @@ -41,6 +41,7 @@ import androidx.compose.ui.unit.dp import com.android.messaging.R import com.android.messaging.ui.conversation.v2.composer.model.ComposerAttachmentUiModel import com.android.messaging.ui.conversation.v2.composer.ui.ConversationSendActionButton +import com.android.messaging.ui.conversation.v2.composer.ui.ConversationSendActionButtonMode import com.android.messaging.ui.conversation.v2.mediapicker.component.PickerOverlayIconButton import kotlinx.collections.immutable.ImmutableList @@ -349,7 +350,12 @@ private fun ConversationMediaReviewBottomBar( ConversationSendActionButton( enabled = isSendActionEnabled, + mode = ConversationSendActionButtonMode.Send, + isRecordingActive = false, onClick = onSendClick, + onRecordGestureStart = {}, + onRecordGestureMove = {}, + onRecordGestureFinish = {}, ) } } diff --git a/src/com/android/messaging/ui/conversation/v2/messages/ui/attachment/ConversationInlineAudioAttachmentPlaybackState.kt b/src/com/android/messaging/ui/conversation/v2/messages/ui/attachment/ConversationInlineAudioAttachmentPlaybackState.kt index fda42b37..0f798a76 100644 --- a/src/com/android/messaging/ui/conversation/v2/messages/ui/attachment/ConversationInlineAudioAttachmentPlaybackState.kt +++ b/src/com/android/messaging/ui/conversation/v2/messages/ui/attachment/ConversationInlineAudioAttachmentPlaybackState.kt @@ -13,8 +13,8 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.core.net.toUri import com.android.messaging.R +import com.android.messaging.ui.conversation.v2.audio.formatConversationAudioDuration import com.android.messaging.util.UiUtils -import java.util.Locale import kotlin.time.Duration.Companion.milliseconds import kotlinx.coroutines.delay @@ -207,9 +207,6 @@ private fun formatAudioDuration( positionMillis > 0L -> positionMillis else -> durationMillis } - val totalSeconds = displayedMillis / 1_000L - val minutes = totalSeconds / 60L - val seconds = totalSeconds % 60L - return String.format(Locale.getDefault(), "%02d:%02d", minutes, seconds) + return formatConversationAudioDuration(durationMillis = displayedMillis) } diff --git a/src/com/android/messaging/ui/conversation/v2/screen/ConversationScreen.kt b/src/com/android/messaging/ui/conversation/v2/screen/ConversationScreen.kt index 77fb63f5..997e4e21 100644 --- a/src/com/android/messaging/ui/conversation/v2/screen/ConversationScreen.kt +++ b/src/com/android/messaging/ui/conversation/v2/screen/ConversationScreen.kt @@ -1,5 +1,6 @@ package com.android.messaging.ui.conversation.v2.screen +import android.Manifest import androidx.activity.compose.BackHandler import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts @@ -27,6 +28,7 @@ import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.geometry.Rect as ComposeRect import androidx.compose.ui.layout.boundsInWindow import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource @@ -42,6 +44,8 @@ import com.android.messaging.ui.conversation.v2.composer.ui.ConversationComposer import com.android.messaging.ui.conversation.v2.composer.ui.ConversationSimSelectorSheet import com.android.messaging.ui.conversation.v2.entry.model.ConversationEntryStartupAttachment import com.android.messaging.ui.conversation.v2.mediapicker.ConversationMediaPickerOverlay +import com.android.messaging.ui.conversation.v2.mediapicker.RefreshConversationMediaPickerPermissionsEffect +import com.android.messaging.ui.conversation.v2.mediapicker.rememberConversationMediaPickerPermissionState import com.android.messaging.ui.conversation.v2.mediapicker.rememberConversationMediaPickerState import com.android.messaging.ui.conversation.v2.messages.model.message.ConversationMessageUiModel import com.android.messaging.ui.conversation.v2.messages.model.message.ConversationMessagesUiState @@ -75,15 +79,37 @@ internal fun ConversationScreen( .mediaPickerOverlayUiState .collectAsStateWithLifecycle() + val context = LocalContext.current + val permissionState = rememberConversationMediaPickerPermissionState(context = context) + val hostBoundsState = remember { mutableStateOf(value = null) } + + var shouldStartAudioRecordingAfterPermissionGrant by rememberSaveable { + mutableStateOf(value = false) + } + val contactPickerLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.PickContact(), ) { contactUri -> screenModel.onContactCardPicked(contactUri = contactUri?.toString()) } + val audioPermissionLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestPermission(), + ) { isGranted -> + permissionState.audioPermissionGranted = isGranted + + if (!isGranted || !shouldStartAudioRecordingAfterPermissionGrant) { + shouldStartAudioRecordingAfterPermissionGrant = false + return@rememberLauncherForActivityResult + } + + shouldStartAudioRecordingAfterPermissionGrant = false + screenModel.onAudioRecordingStart() + } + LaunchedEffect(conversationId) { screenModel.onConversationIdChanged(conversationId = conversationId) } @@ -124,7 +150,15 @@ internal fun ConversationScreen( } } + RefreshConversationMediaPickerPermissionsEffect( + context = context, + permissionState = permissionState, + ) + LifecycleEventEffect(event = Lifecycle.Event.ON_STOP) { + if (scaffoldUiState.composer.audioRecording.isRecording) { + screenModel.onAudioRecordingCancel() + } screenModel.persistDraft() } @@ -176,6 +210,16 @@ internal fun ConversationScreen( onPendingAttachmentRemove = screenModel::onRemovePendingAttachment, onResolvedAttachmentClick = screenModel::onAttachmentClicked, onResolvedAttachmentRemove = screenModel::onRemoveResolvedAttachment, + onAudioRecordingStartRequest = { + if (permissionState.audioPermissionGranted) { + screenModel.onAudioRecordingStart() + } else { + shouldStartAudioRecordingAfterPermissionGrant = true + audioPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO) + } + }, + onAudioRecordingFinish = screenModel::onAudioRecordingFinish, + onAudioRecordingCancel = screenModel::onAudioRecordingCancel, onSendClick = screenModel::onSendClick, onSimSelected = screenModel::onSimSelected, onAttachmentClick = screenModel::onMessageAttachmentClicked, @@ -233,6 +277,9 @@ private fun ConversationScreenScaffold( onPendingAttachmentRemove: (String) -> Unit, onResolvedAttachmentClick: (ComposerAttachmentUiModel.Resolved) -> Unit, onResolvedAttachmentRemove: (String) -> Unit, + onAudioRecordingStartRequest: () -> Unit, + onAudioRecordingFinish: () -> Unit, + onAudioRecordingCancel: () -> Unit, onSendClick: () -> Unit, onSimSelected: (String) -> Unit, onAttachmentClick: (contentType: String, contentUri: String) -> Unit, @@ -285,11 +332,14 @@ private fun ConversationScreenScaffold( bottomBar = { if (!isMediaPickerOpen) { ConversationComposerSection( + audioRecording = uiState.composer.audioRecording, attachments = uiState.composer.attachments, messageText = uiState.composer.messageText, isMessageFieldEnabled = uiState.composer.isMessageFieldEnabled, isAttachmentActionEnabled = uiState.composer.isAttachmentActionEnabled, + isRecordActionEnabled = uiState.composer.isRecordActionEnabled, isSendActionEnabled = uiState.composer.isSendEnabled, + shouldShowRecordAction = uiState.composer.shouldShowRecordAction, messageFieldFocusRequester = messageFieldFocusRequester, onContactAttachClick = onOpenContactPicker, onMediaPickerClick = onOpenMediaPicker, @@ -297,6 +347,9 @@ private fun ConversationScreenScaffold( onPendingAttachmentRemove = onPendingAttachmentRemove, onResolvedAttachmentClick = onResolvedAttachmentClick, onResolvedAttachmentRemove = onResolvedAttachmentRemove, + onAudioRecordingStartRequest = onAudioRecordingStartRequest, + onAudioRecordingFinish = onAudioRecordingFinish, + onAudioRecordingCancel = onAudioRecordingCancel, onSendClick = onSendClick, ) } diff --git a/src/com/android/messaging/ui/conversation/v2/screen/ConversationViewModel.kt b/src/com/android/messaging/ui/conversation/v2/screen/ConversationViewModel.kt index 3ebc62bc..b923f835 100644 --- a/src/com/android/messaging/ui/conversation/v2/screen/ConversationViewModel.kt +++ b/src/com/android/messaging/ui/conversation/v2/screen/ConversationViewModel.kt @@ -10,6 +10,7 @@ import com.android.messaging.datamodel.MessagingContentProvider import com.android.messaging.di.core.DefaultDispatcher import com.android.messaging.domain.conversation.usecase.CanAddMoreConversationParticipants import com.android.messaging.domain.conversation.usecase.IsDeviceVoiceCapable +import com.android.messaging.ui.conversation.v2.audio.delegate.ConversationAudioRecordingDelegate import com.android.messaging.ui.conversation.v2.composer.delegate.ConversationComposerAttachmentsDelegate import com.android.messaging.ui.conversation.v2.composer.delegate.ConversationDraftDelegate import com.android.messaging.ui.conversation.v2.composer.mapper.ConversationComposerUiStateMapper @@ -79,6 +80,9 @@ internal interface ConversationScreenModel { fun onGalleryMediaConfirmed(mediaItems: List) fun onContactCardPicked(contactUri: String?) fun onMessageTextChanged(text: String) + fun onAudioRecordingStart() + fun onAudioRecordingFinish() + fun onAudioRecordingCancel() fun onGalleryVisibilityChanged(isVisible: Boolean) fun onCapturedMediaReady(capturedMedia: ConversationCapturedMedia) fun onRemovePendingAttachment(pendingAttachmentId: String) @@ -104,6 +108,7 @@ internal interface ConversationScreenModel { @HiltViewModel internal class ConversationViewModel @Inject constructor( + private val conversationAudioRecordingDelegate: ConversationAudioRecordingDelegate, private val conversationComposerAttachmentsDelegate: ConversationComposerAttachmentsDelegate, private val conversationDraftDelegate: ConversationDraftDelegate, private val conversationMessagesDelegate: ConversationMessagesDelegate, @@ -145,12 +150,14 @@ internal class ConversationViewModel @Inject constructor( } private val composerUiState = combine( + conversationAudioRecordingDelegate.state, conversationMetadataDelegate.state, conversationDraftDelegate.state, conversationComposerAttachmentsDelegate.state, subscriptionsFlow, - ) { metadataState, draftState, attachments, subscriptions -> + ) { audioRecordingState, metadataState, draftState, attachments, subscriptions -> conversationComposerUiStateMapper.map( + audioRecording = audioRecordingState, draftState = draftState, attachments = attachments, composerAvailability = metadataState.composerAvailability, @@ -162,6 +169,7 @@ internal class ConversationViewModel @Inject constructor( stopTimeoutMillis = STATEFLOW_STOP_TIMEOUT_MILLIS, ), initialValue = conversationComposerUiStateMapper.map( + audioRecording = conversationAudioRecordingDelegate.state.value, draftState = conversationDraftDelegate.state.value, attachments = conversationComposerAttachmentsDelegate.state.value, composerAvailability = conversationMetadataDelegate.state.value.composerAvailability, @@ -253,6 +261,10 @@ internal class ConversationViewModel @Inject constructor( ) private fun initializeDelegates() { + conversationAudioRecordingDelegate.bind( + scope = viewModelScope, + conversationIdFlow = conversationIdFlow, + ) conversationDraftDelegate.bind( scope = viewModelScope, conversationIdFlow = conversationIdFlow, @@ -464,6 +476,26 @@ internal class ConversationViewModel @Inject constructor( conversationDraftDelegate.onMessageTextChanged(messageText = text) } + override fun onAudioRecordingStart() { + val effectiveSelfParticipantId = composerUiState.value + .simSelector + .selectedSubscription + ?.selfParticipantId + ?: conversationDraftDelegate.state.value.draft.selfParticipantId + + conversationAudioRecordingDelegate.startRecording( + selfParticipantId = effectiveSelfParticipantId, + ) + } + + override fun onAudioRecordingFinish() { + conversationAudioRecordingDelegate.finishRecording() + } + + override fun onAudioRecordingCancel() { + conversationAudioRecordingDelegate.cancelRecording() + } + override fun onGalleryVisibilityChanged(isVisible: Boolean) { conversationMediaPickerDelegate.onGalleryVisibilityChanged(isVisible = isVisible) } @@ -535,6 +567,7 @@ internal class ConversationViewModel @Inject constructor( } override fun onCleared() { + conversationAudioRecordingDelegate.onScreenCleared() conversationMediaPickerDelegate.onScreenCleared() conversationDraftDelegate.flushDraft() From d4e379f64359b7580bd3b5e291d6ce89b304954f Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Thu, 23 Apr 2026 17:14:38 +0300 Subject: [PATCH 55/99] Improve phone country code detection --- .../datamodel/data/ParticipantData.java | 7 +- .../android/messaging/util/PhoneUtils.java | 148 +++++++++++++++++- 2 files changed, 151 insertions(+), 4 deletions(-) diff --git a/src/com/android/messaging/datamodel/data/ParticipantData.java b/src/com/android/messaging/datamodel/data/ParticipantData.java index 95c74e24..2e501f7f 100644 --- a/src/com/android/messaging/datamodel/data/ParticipantData.java +++ b/src/com/android/messaging/datamodel/data/ParticipantData.java @@ -179,7 +179,7 @@ public static ParticipantData getFromRecipientEntry(final RecipientEntry recipie pd.mIsEmailAddress = MmsSmsUtils.isEmailAddress(pd.mSendDestination); pd.mNormalizedDestination = pd.mIsEmailAddress ? pd.mSendDestination : - PhoneUtils.getDefault().getCanonicalBySystemLocale(pd.mSendDestination); + PhoneUtils.getDefault().getCanonicalForEnteredPhoneNumber(pd.mSendDestination); pd.mDisplayDestination = pd.mIsEmailAddress ? pd.mNormalizedDestination : PhoneUtils.getDefault().formatForDisplay(pd.mNormalizedDestination); @@ -223,7 +223,8 @@ private static ParticipantData getFromRawPhone(final String phoneNumber) { } /** - * Get an instance from a raw phone number and using system locale to normalize it. + * Get an instance from a raw phone number and using the best available telephony or locale + * signal to normalize it. * * Use this when creating a participant that is for displaying UI and not associated * with a specific SIM. For example, when creating a conversation using user entered @@ -236,7 +237,7 @@ public static ParticipantData getFromRawPhoneBySystemLocale(final String phoneNu final ParticipantData pd = getFromRawPhone(phoneNumber); pd.mNormalizedDestination = pd.mIsEmailAddress ? pd.mSendDestination : - PhoneUtils.getDefault().getCanonicalBySystemLocale(pd.mSendDestination); + PhoneUtils.getDefault().getCanonicalForEnteredPhoneNumber(pd.mSendDestination); pd.mDisplayDestination = pd.mIsEmailAddress ? pd.mNormalizedDestination : PhoneUtils.getDefault().formatForDisplay(pd.mNormalizedDestination); diff --git a/src/com/android/messaging/util/PhoneUtils.java b/src/com/android/messaging/util/PhoneUtils.java index a09a0892..ab1ebbb1 100644 --- a/src/com/android/messaging/util/PhoneUtils.java +++ b/src/com/android/messaging/util/PhoneUtils.java @@ -39,12 +39,16 @@ import com.google.i18n.phonenumbers.PhoneNumberUtil; import com.google.i18n.phonenumbers.PhoneNumberUtil.PhoneNumberFormat; import com.google.i18n.phonenumbers.Phonenumber.PhoneNumber; +import com.google.common.annotations.VisibleForTesting; import java.util.ArrayList; import java.util.HashSet; +import java.util.LinkedHashSet; import java.util.List; import java.util.Locale; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import androidx.collection.ArrayMap; /** @@ -445,6 +449,28 @@ public String getSimOrDefaultLocaleCountry() { return country; } + @Nullable + public String getNetworkCountry() { + try { + final String country; + + if (mSubId == ParticipantData.DEFAULT_SELF_SUB_ID) { + country = mTelephonyManager.getNetworkCountryIso(); + } else { + country = mTelephonyManager.createForSubscriptionId(mSubId).getNetworkCountryIso(); + } + + if (TextUtils.isEmpty(country)) { + return null; + } + + return country.toUpperCase(); + } catch (final Exception e) { + LogUtil.e(TAG, "PhoneUtils.getNetworkCountry(): system exception for " + mSubId, e); + return null; + } + } + // Get or set the cache of canonicalized phone numbers for a specific country private static ArrayMap getOrAddCountryMapInCacheLocked(String country) { if (country == null) { @@ -482,10 +508,24 @@ private static void putCanonicalToCache(final String phoneText, String country, * @param country ISO country code based on which to parse the number. * @return E164 phone number. Returns null in case parsing failed. */ - private static String getValidE164Number(final String phoneText, final String country) { + @Nullable + private static String getValidE164Number( + @NonNull final String phoneText, + @Nullable final String country + ) { + if (!TextUtils.isEmpty(country)) { + final String frameworkE164Number = PhoneNumberUtils + .formatNumberToE164(phoneText, country); + + if (!TextUtils.isEmpty(frameworkE164Number)) { + return frameworkE164Number; + } + } + final PhoneNumberUtil phoneNumberUtil = PhoneNumberUtil.getInstance(); try { final PhoneNumber phoneNumber = phoneNumberUtil.parse(phoneText, country); + if (phoneNumber != null && phoneNumberUtil.isValidNumber(phoneNumber)) { return phoneNumberUtil.format(phoneNumber, PhoneNumberFormat.E164); } @@ -493,6 +533,7 @@ private static String getValidE164Number(final String phoneText, final String co LogUtil.e(TAG, "PhoneUtils.getValidE164Number(): Not able to parse phone number " + LogUtil.sanitizePII(phoneText) + " for country " + country); } + return null; } @@ -506,6 +547,55 @@ public String getCanonicalBySystemLocale(final String phoneText) { return getCanonicalByCountry(phoneText, getLocaleCountry()); } + /** + * Canonicalize phone number using the best currently available country signal for manually + * entered destinations. The priority is network country, subscription countries, then locale. + * + * @param phoneText The phone number to canonicalize + * @return the canonicalized number + */ + public String getCanonicalForEnteredPhoneNumber(@NonNull final String phoneText) { + if (phoneText.isEmpty()) { + return phoneText; + } + + if (phoneText.charAt(0) == '+') { + final String canonicalNumber = getValidE164Number(phoneText, null); + return canonicalNumber != null ? canonicalNumber : phoneText; + } + + return getCanonicalByCountryCandidates( + phoneText, + getCountryCandidatesForEnteredPhoneNumber() + ); + } + + @NonNull + private String getCanonicalByCountryCandidates( + @NonNull final String phoneText, + final Iterable countryCandidates + ) { + for (final String country : countryCandidates) { + final String cachedCanonicalNumber = getCanonicalFromCache(phoneText, country); + if (cachedCanonicalNumber != null) { + if (!TextUtils.equals(cachedCanonicalNumber, phoneText)) { + return cachedCanonicalNumber; + } + continue; + } + + final String canonicalNumber = getValidE164Number(phoneText, country); + if (canonicalNumber != null) { + putCanonicalToCache(phoneText, country, canonicalNumber); + return canonicalNumber; + } + + putCanonicalToCache(phoneText, country, phoneText); + } + + return phoneText; + } + /** * Canonicalize phone number using SIM's country, may fall back to system locale country * if SIM country can not be obtained @@ -517,6 +607,54 @@ public String getCanonicalBySimLocale(final String phoneText) { return getCanonicalByCountry(phoneText, getSimOrDefaultLocaleCountry()); } + @VisibleForTesting + List getCountryCandidatesForEnteredPhoneNumber() { + final LinkedHashSet uniqueCountries = new LinkedHashSet<>(); + String normalizedCountryCode = normalizeCountryCode(getNetworkCountry()); + if (normalizedCountryCode != null) { + uniqueCountries.add(normalizedCountryCode); + } + + final int defaultSmsSubId = getDefaultSmsSubscriptionId(); + if (defaultSmsSubId != ParticipantData.DEFAULT_SELF_SUB_ID) { + final PhoneUtils defaultSmsPhoneUtils = PhoneUtils.get(defaultSmsSubId); + normalizedCountryCode = normalizeCountryCode(defaultSmsPhoneUtils.getNetworkCountry()); + if (normalizedCountryCode != null) { + uniqueCountries.add(normalizedCountryCode); + } + + normalizedCountryCode = normalizeCountryCode(defaultSmsPhoneUtils.getSimCountry()); + if (normalizedCountryCode != null) { + uniqueCountries.add(normalizedCountryCode); + } + } + + for (final SubscriptionInfo subscriptionInfo : getActiveSubscriptionInfoList()) { + normalizedCountryCode = normalizeCountryCode(subscriptionInfo.getCountryIso()); + if (normalizedCountryCode != null) { + uniqueCountries.add(normalizedCountryCode); + } + + final PhoneUtils subscriptionPhoneUtils = + PhoneUtils.get(subscriptionInfo.getSubscriptionId()); + normalizedCountryCode = normalizeCountryCode(subscriptionPhoneUtils.getNetworkCountry()); + if (normalizedCountryCode != null) { + uniqueCountries.add(normalizedCountryCode); + } + + normalizedCountryCode = normalizeCountryCode(subscriptionPhoneUtils.getSimCountry()); + if (normalizedCountryCode != null) { + uniqueCountries.add(normalizedCountryCode); + } + } + + normalizedCountryCode = normalizeCountryCode(getLocaleCountry()); + if (normalizedCountryCode != null) { + uniqueCountries.add(normalizedCountryCode); + } + return new ArrayList<>(uniqueCountries); + } + /** * Canonicalize phone number using a country code. * This uses an internal cache per country to speed up. @@ -542,6 +680,14 @@ private String getCanonicalByCountry(final String phoneText, final String countr return canonicalNumber; } + @Nullable + private static String normalizeCountryCode(final String country) { + if (!TextUtils.isEmpty(country)) { + return country.toUpperCase(); + } + return null; + } + /** * Canonicalize the self (per SIM) phone number * From 65931a3de7430428a708128122fd754a35cf0326 Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Thu, 23 Apr 2026 17:55:05 +0300 Subject: [PATCH 56/99] Add ability to start a chat with a phone number, not only a contact --- .../RecipientSelectionContent.kt | 140 ++++++++------- .../RecipientSelectionContentUiState.kt | 6 +- .../delegate/RecipientPickerDelegate.kt | 160 +++++++++++++++++- .../model/RecipientPickerListItem.kt | 28 +++ .../model/RecipientPickerUiState.kt | 3 +- 5 files changed, 266 insertions(+), 71 deletions(-) create mode 100644 src/com/android/messaging/ui/conversation/v2/recipientpicker/model/RecipientPickerListItem.kt diff --git a/src/com/android/messaging/ui/conversation/v2/recipientpicker/RecipientSelectionContent.kt b/src/com/android/messaging/ui/conversation/v2/recipientpicker/RecipientSelectionContent.kt index c024711f..f6a7fb56 100644 --- a/src/com/android/messaging/ui/conversation/v2/recipientpicker/RecipientSelectionContent.kt +++ b/src/com/android/messaging/ui/conversation/v2/recipientpicker/RecipientSelectionContent.kt @@ -80,7 +80,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage import com.android.messaging.R -import com.android.messaging.data.conversation.model.recipient.ConversationRecipient +import com.android.messaging.ui.conversation.v2.recipientpicker.model.RecipientPickerListItem private val contactCornerRadius = 18.dp private val contactMiddleCornerRadius = 2.dp @@ -108,12 +108,12 @@ internal fun RecipientSelectionContent( uiState: RecipientSelectionContentUiState, strings: RecipientSelectionStrings, rowDecorators: RecipientSelectionRowDecorators, - onRecipientClick: (ConversationRecipient) -> Unit, + onRecipientClick: (RecipientPickerListItem) -> Unit, modifier: Modifier = Modifier, onLoadMore: () -> Unit = {}, onPrimaryActionClick: () -> Unit = {}, onQueryChanged: (String) -> Unit = {}, - onRecipientLongClick: ((ConversationRecipient) -> Unit)? = null, + onRecipientLongClick: ((RecipientPickerListItem) -> Unit)? = null, topListContent: (@Composable () -> Unit)? = null, ) { Surface( @@ -202,14 +202,14 @@ private fun RecipientSelectionContactsContent( rowDecorators: RecipientSelectionRowDecorators, onLoadMore: () -> Unit, onPrimaryActionClick: () -> Unit, - onRecipientClick: (ConversationRecipient) -> Unit, - onRecipientLongClick: ((ConversationRecipient) -> Unit)?, + onRecipientClick: (RecipientPickerListItem) -> Unit, + onRecipientLongClick: ((RecipientPickerListItem) -> Unit)?, modifier: Modifier = Modifier, topListContent: (@Composable () -> Unit)? = null, ) { val pickerUiState = uiState.picker val primaryAction = uiState.primaryAction - val lastContactIndex = pickerUiState.contacts.lastIndex + val lastContactIndex = pickerUiState.items.lastIndex val listState = rememberLazyListState() val animatedListBottomPadding by animateDpAsState( @@ -226,7 +226,7 @@ private fun RecipientSelectionContactsContent( pickerUiState.canLoadMore, pickerUiState.isLoading, pickerUiState.isLoadingMore, - pickerUiState.contacts.size, + pickerUiState.items.size, ) { snapshotFlow { val lastVisibleIndex = listState @@ -268,7 +268,7 @@ private fun RecipientSelectionContactsContent( } } - pickerUiState.contacts.isEmpty() || !pickerUiState.hasContactsPermission -> { + pickerUiState.items.isEmpty() -> { item { RecipientSelectionEmptyState() } @@ -276,10 +276,10 @@ private fun RecipientSelectionContactsContent( else -> { itemsIndexed( - items = pickerUiState.contacts, - key = { _, contact -> contact.id }, + items = pickerUiState.items, + key = { _, item -> item.id }, contentType = { _, _ -> RECIPIENT_CONTACT_CONTENT_TYPE }, - ) { index, contact -> + ) { index, item -> val bottomPadding = when { index == lastContactIndex -> 0.dp else -> 2.dp @@ -287,27 +287,27 @@ private fun RecipientSelectionContactsContent( RecipientSelectionContactRow( modifier = Modifier.padding(bottom = bottomPadding), - contact = contact, + item = item, enabled = primaryAction?.isLoading != true, isSelected = uiState.selectedRecipientDestinations.contains( - contact.destination, + item.destination, ), onClick = { - onRecipientClick(contact) + onRecipientClick(item) }, onLongClick = onRecipientLongClick?.let { callback -> { - callback(contact) + callback(item) } }, - rowTestTag = rowDecorators.recipientRowTestTag(contact), + rowTestTag = rowDecorators.recipientRowTestTag(item), shape = recipientSelectionContactRowShape( index = index, - totalCount = pickerUiState.contacts.size, + totalCount = pickerUiState.items.size, ), showTrailingIndicator = rowDecorators .showRecipientTrailingIndicator( - contact, + item, ), trailingIndicatorTestTag = rowDecorators.trailingIndicatorTestTag, ) @@ -442,7 +442,7 @@ private fun RecipientSelectionPrimaryActionButton( @Composable private fun RecipientSelectionContactRow( - contact: ConversationRecipient, + item: RecipientPickerListItem, enabled: Boolean, isSelected: Boolean, onClick: () -> Unit, @@ -492,7 +492,7 @@ private fun RecipientSelectionContactRow( verticalAlignment = Alignment.CenterVertically, ) { RecipientSelectionContactAvatar( - contact = contact, + item = item, isSelected = isSelected, ) @@ -503,14 +503,14 @@ private fun RecipientSelectionContactRow( verticalArrangement = Arrangement.spacedBy(space = 2.dp), ) { Text( - text = contact.displayName, + text = recipientSelectionItemDisplayName(item = item), maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.bodyLarge, color = primaryTextColor, ) - contact.secondaryText?.let { secondaryText -> + item.secondaryText?.let { secondaryText -> Text( text = secondaryText, maxLines = 1, @@ -559,7 +559,7 @@ private fun recipientSelectionContactRowShape( @Composable private fun RecipientSelectionContactAvatar( - contact: ConversationRecipient, + item: RecipientPickerListItem, isSelected: Boolean, ) { val avatarScale by rememberRecipientSelectionContactAvatarScale( @@ -584,17 +584,17 @@ private fun RecipientSelectionContactAvatar( RecipientSelectionSelectedAvatar() } - contact.photoUri == null -> { - RecipientSelectionTextAvatar(contact = contact) + recipientSelectionPhotoUri(item) == null -> { + RecipientSelectionTextAvatar(item) } else -> { AsyncImage( - model = contact.photoUri, - contentDescription = contact.displayName, modifier = Modifier .size(size = 40.dp) .clip(shape = CircleShape), + model = recipientSelectionPhotoUri(item), + contentDescription = recipientSelectionItemDisplayName(item), ) } } @@ -625,11 +625,15 @@ private fun RecipientSelectionSelectedAvatar( @Composable private fun RecipientSelectionTextAvatar( - contact: ConversationRecipient, + item: RecipientPickerListItem, modifier: Modifier = Modifier, ) { - val label = remember(contact.displayName, contact.destination) { - recipientSelectionAvatarLabel(contact = contact) + val displayName = recipientSelectionItemDisplayName(item = item) + val label = remember(displayName, item.destination) { + recipientSelectionAvatarLabel( + displayName = displayName, + destination = item.destination, + ) } Box( @@ -649,10 +653,33 @@ private fun RecipientSelectionTextAvatar( } } +@Composable +private fun recipientSelectionItemDisplayName( + item: RecipientPickerListItem, +): String { + return when (item) { + is RecipientPickerListItem.Contact -> item.recipient.displayName + is RecipientPickerListItem.SyntheticPhone -> { + stringResource( + id = R.string.contact_list_send_to_text, + item.rawQuery, + ) + } + } +} + +private fun recipientSelectionPhotoUri(item: RecipientPickerListItem): String? { + return when (item) { + is RecipientPickerListItem.Contact -> item.recipient.photoUri + is RecipientPickerListItem.SyntheticPhone -> null + } +} + private fun recipientSelectionAvatarLabel( - contact: ConversationRecipient, + displayName: String, + destination: String, ): String { - val labelSource = contact.displayName.ifBlank { contact.destination } + val labelSource = displayName.ifBlank { destination } val firstCharacter = labelSource.firstOrNull() ?: '?' return firstCharacter.uppercaseChar().toString() @@ -687,20 +714,8 @@ private fun recipientSelectionPrimaryActionExitTransition(): ExitTransition { } private fun recipientSelectionPrimaryActionContentTransform(): ContentTransform { - return ( - fadeIn( - animationSpec = recipientSelectionDefaultEffectsAnimationSpec(), - ) + scaleIn( - animationSpec = recipientSelectionSpatialAnimationSpec(), - initialScale = 0.9f, - ) - ).togetherWith( - fadeOut( - animationSpec = recipientSelectionFastEffectsAnimationSpec(), - ) + scaleOut( - animationSpec = recipientSelectionSpatialAnimationSpec(), - targetScale = 0.9f, - ), + return recipientSelectionFadeAndScaleContentTransform( + scale = 0.9f, ) } @@ -723,23 +738,28 @@ private fun recipientSelectionTrailingIndicatorExitTransition(): ExitTransition } private fun recipientSelectionAvatarContentTransform(): ContentTransform { - return ( - fadeIn( - animationSpec = recipientSelectionDefaultEffectsAnimationSpec(), - ) + scaleIn( - animationSpec = recipientSelectionSpatialAnimationSpec(), - initialScale = 0.8f, - ) - ).togetherWith( - fadeOut( - animationSpec = recipientSelectionFastEffectsAnimationSpec(), - ) + scaleOut( - animationSpec = recipientSelectionSpatialAnimationSpec(), - targetScale = 0.8f, - ), + return recipientSelectionFadeAndScaleContentTransform( + scale = 0.8f, ) } +private fun recipientSelectionFadeAndScaleContentTransform(scale: Float): ContentTransform { + val enterTransition = fadeIn( + animationSpec = recipientSelectionDefaultEffectsAnimationSpec(), + ) + scaleIn( + animationSpec = recipientSelectionSpatialAnimationSpec(), + initialScale = scale, + ) + val exitTransition = fadeOut( + animationSpec = recipientSelectionFastEffectsAnimationSpec(), + ) + scaleOut( + animationSpec = recipientSelectionSpatialAnimationSpec(), + targetScale = scale, + ) + + return enterTransition.togetherWith(exitTransition) +} + @Composable private fun rememberRecipientSelectionContactAvatarScale( isSelected: Boolean, diff --git a/src/com/android/messaging/ui/conversation/v2/recipientpicker/RecipientSelectionContentUiState.kt b/src/com/android/messaging/ui/conversation/v2/recipientpicker/RecipientSelectionContentUiState.kt index 5fd871a0..6de1bcae 100644 --- a/src/com/android/messaging/ui/conversation/v2/recipientpicker/RecipientSelectionContentUiState.kt +++ b/src/com/android/messaging/ui/conversation/v2/recipientpicker/RecipientSelectionContentUiState.kt @@ -1,7 +1,7 @@ package com.android.messaging.ui.conversation.v2.recipientpicker import androidx.compose.runtime.Immutable -import com.android.messaging.data.conversation.model.recipient.ConversationRecipient +import com.android.messaging.ui.conversation.v2.recipientpicker.model.RecipientPickerListItem import com.android.messaging.ui.conversation.v2.recipientpicker.model.RecipientPickerUiState import kotlinx.collections.immutable.ImmutableSet import kotlinx.collections.immutable.persistentSetOf @@ -29,7 +29,7 @@ internal data class RecipientSelectionStrings( ) internal data class RecipientSelectionRowDecorators( - val recipientRowTestTag: (ConversationRecipient) -> String, - val showRecipientTrailingIndicator: (ConversationRecipient) -> Boolean = { false }, + val recipientRowTestTag: (RecipientPickerListItem) -> String, + val showRecipientTrailingIndicator: (RecipientPickerListItem) -> Boolean = { false }, val trailingIndicatorTestTag: String? = null, ) diff --git a/src/com/android/messaging/ui/conversation/v2/recipientpicker/delegate/RecipientPickerDelegate.kt b/src/com/android/messaging/ui/conversation/v2/recipientpicker/delegate/RecipientPickerDelegate.kt index 5a330420..b7f5bb43 100644 --- a/src/com/android/messaging/ui/conversation/v2/recipientpicker/delegate/RecipientPickerDelegate.kt +++ b/src/com/android/messaging/ui/conversation/v2/recipientpicker/delegate/RecipientPickerDelegate.kt @@ -6,7 +6,10 @@ import com.android.messaging.data.conversation.repository.ConversationRecipients import com.android.messaging.data.conversation.repository.ConversationRecipientsRepository import com.android.messaging.di.core.DefaultDispatcher import com.android.messaging.domain.contacts.usecase.IsReadContactsPermissionGranted +import com.android.messaging.sms.MmsSmsUtils +import com.android.messaging.ui.conversation.v2.recipientpicker.model.RecipientPickerListItem import com.android.messaging.ui.conversation.v2.recipientpicker.model.RecipientPickerUiState +import com.android.messaging.util.PhoneUtils import javax.inject.Inject import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf @@ -63,6 +66,8 @@ internal class RecipientPickerDelegateImpl @Inject constructor( private var boundScope: CoroutineScope? = null + private val phoneUtils by lazy { PhoneUtils.getDefault() } + private var searchSession = RecipientSearchSession( effectiveQuery = queryFlow.value, hasCompletedInitialLoad = false, @@ -159,6 +164,12 @@ internal class RecipientPickerDelegateImpl @Inject constructor( } private suspend fun applyPermissionDeniedState(query: String) { + val visibleRecipients = buildVisibleRecipients( + query = query, + recipients = persistentListOf(), + excludedDestinations = excludedDestinationsFlow.value, + ) + updateSearchSession { currentSearchSession -> currentSearchSession.copy( effectiveQuery = query, @@ -169,7 +180,7 @@ internal class RecipientPickerDelegateImpl @Inject constructor( _state.update { currentState -> currentState.copy( canLoadMore = false, - contacts = persistentListOf(), + items = visibleRecipients, hasContactsPermission = false, isLoading = false, isLoadingMore = false, @@ -268,7 +279,11 @@ internal class RecipientPickerDelegateImpl @Inject constructor( private fun applyInitialSearchResult(result: InitialSearchResult) { _state.update { currentState -> currentState.copy( - contacts = result.page.recipients, + items = buildVisibleRecipients( + query = currentState.query, + recipients = result.page.recipients, + excludedDestinations = excludedDestinationsFlow.value, + ), canLoadMore = result.page.nextOffset != null, hasContactsPermission = true, isLoading = false, @@ -349,11 +364,24 @@ internal class RecipientPickerDelegateImpl @Inject constructor( private fun applyLoadMoreResult(page: ConversationRecipientsPage) { _state.update { currentState -> + val mergedRecipients = mergeRecipients( + existingRecipients = currentState.items.mapNotNull { item -> + when (item) { + is RecipientPickerListItem.Contact -> item.recipient + is RecipientPickerListItem.SyntheticPhone -> null + } + }, + additionalRecipients = page.recipients, + ) + + val visibleRecipients = buildVisibleRecipients( + query = currentState.query, + recipients = mergedRecipients, + excludedDestinations = excludedDestinationsFlow.value, + ) + currentState.copy( - contacts = mergeRecipients( - existingRecipients = currentState.contacts, - additionalRecipients = page.recipients, - ), + items = visibleRecipients, canLoadMore = page.nextOffset != null, isLoadingMore = false, ) @@ -376,6 +404,87 @@ internal class RecipientPickerDelegateImpl @Inject constructor( } } + private fun buildVisibleRecipients( + query: String, + recipients: List, + excludedDestinations: Set, + ): ImmutableList { + val syntheticRecipient = createSyntheticRecipientOrNull( + query = query, + recipients = recipients, + excludedDestinations = excludedDestinations, + ) + + val contactItems = recipients + .map(RecipientPickerListItem::Contact) + .toImmutableList() + + if (syntheticRecipient == null) { + return contactItems + } + + return persistentListOf(syntheticRecipient) + .addAll(contactItems) + } + + private fun createSyntheticRecipientOrNull( + query: String, + recipients: List, + excludedDestinations: Set, + ): RecipientPickerListItem.SyntheticPhone? { + val candidate = createSyntheticRecipientCandidateOrNull(query = query) ?: return null + + return when { + candidate.isExcludedBy(excludedDestinations) -> null + recipients.any { recipient -> candidate.matches(recipient) } -> null + else -> candidate.toListItem() + } + } + + private fun createSyntheticRecipientCandidateOrNull( + query: String, + ): SyntheticRecipientCandidate? { + val trimmedQuery = query.trim() + + return when { + trimmedQuery.isEmpty() -> null + !PhoneUtils.isValidSmsMmsDestination(trimmedQuery) -> null + else -> { + SyntheticRecipientCandidate( + rawQuery = trimmedQuery, + destinationIdentity = createDestinationIdentity( + rawDestination = trimmedQuery, + ), + ) + } + } + } + + private fun createDestinationIdentity(rawDestination: String): DestinationIdentity { + val trimmedDestination = rawDestination.trim() + + return DestinationIdentity( + rawDestination = trimmedDestination, + normalizedDestination = normalizeDestination(rawDestination = trimmedDestination), + ) + } + + private fun SyntheticRecipientCandidate.matches(recipient: ConversationRecipient): Boolean { + return destinationIdentity.matches( + other = createDestinationIdentity(rawDestination = recipient.destination), + ) + } + + private fun normalizeDestination(rawDestination: String): String { + val trimmedDestination = rawDestination.trim() + + return when { + trimmedDestination.isEmpty() -> trimmedDestination + MmsSmsUtils.isEmailAddress(trimmedDestination) -> trimmedDestination + else -> phoneUtils.getCanonicalForEnteredPhoneNumber(trimmedDestination) + } + } + private data class InitialSearchResult( val effectiveQuery: String, val page: ConversationRecipientsPage, @@ -399,8 +508,47 @@ internal class RecipientPickerDelegateImpl @Inject constructor( val excludedDestinations: Set, ) + private data class DestinationIdentity( + val rawDestination: String, + val normalizedDestination: String, + ) { + fun isExcludedBy(excludedDestinations: Set): Boolean { + return rawDestination in excludedDestinations || + normalizedDestination in excludedDestinations + } + + fun matches(other: DestinationIdentity): Boolean { + return matches(destination = other.rawDestination) || + matches(destination = other.normalizedDestination) + } + + private fun matches(destination: String): Boolean { + return destination.isNotEmpty() && + (rawDestination == destination || normalizedDestination == destination) + } + } + + private data class SyntheticRecipientCandidate( + val rawQuery: String, + val destinationIdentity: DestinationIdentity, + ) { + fun isExcludedBy(excludedDestinations: Set): Boolean { + return destinationIdentity.isExcludedBy(excludedDestinations = excludedDestinations) + } + + fun toListItem(): RecipientPickerListItem.SyntheticPhone { + return RecipientPickerListItem.SyntheticPhone( + id = "$SYNTHETIC_RECIPIENT_ID_PREFIX$rawQuery", + rawQuery = rawQuery, + destination = rawQuery, + normalizedDestination = destinationIdentity.normalizedDestination, + ) + } + } + private companion object { private const val SEARCH_DEBOUNCE_MILLIS = 150L private const val SEARCH_QUERY_KEY = "search_query" + private const val SYNTHETIC_RECIPIENT_ID_PREFIX = "synthetic:" } } diff --git a/src/com/android/messaging/ui/conversation/v2/recipientpicker/model/RecipientPickerListItem.kt b/src/com/android/messaging/ui/conversation/v2/recipientpicker/model/RecipientPickerListItem.kt new file mode 100644 index 00000000..26941f5b --- /dev/null +++ b/src/com/android/messaging/ui/conversation/v2/recipientpicker/model/RecipientPickerListItem.kt @@ -0,0 +1,28 @@ +package com.android.messaging.ui.conversation.v2.recipientpicker.model + +import androidx.compose.runtime.Immutable +import com.android.messaging.data.conversation.model.recipient.ConversationRecipient + +@Immutable +internal sealed interface RecipientPickerListItem { + val id: String + val destination: String + val secondaryText: String? + + @Immutable + data class Contact( + val recipient: ConversationRecipient, + override val id: String = recipient.id, + override val destination: String = recipient.destination, + override val secondaryText: String? = recipient.secondaryText, + ) : RecipientPickerListItem + + @Immutable + data class SyntheticPhone( + override val id: String, + val rawQuery: String, + override val destination: String, + val normalizedDestination: String, + override val secondaryText: String = normalizedDestination, + ) : RecipientPickerListItem +} diff --git a/src/com/android/messaging/ui/conversation/v2/recipientpicker/model/RecipientPickerUiState.kt b/src/com/android/messaging/ui/conversation/v2/recipientpicker/model/RecipientPickerUiState.kt index 85650ba9..7b2c0f7e 100644 --- a/src/com/android/messaging/ui/conversation/v2/recipientpicker/model/RecipientPickerUiState.kt +++ b/src/com/android/messaging/ui/conversation/v2/recipientpicker/model/RecipientPickerUiState.kt @@ -1,14 +1,13 @@ package com.android.messaging.ui.conversation.v2.recipientpicker.model import androidx.compose.runtime.Immutable -import com.android.messaging.data.conversation.model.recipient.ConversationRecipient import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf @Immutable internal data class RecipientPickerUiState( val query: String = "", - val contacts: ImmutableList = persistentListOf(), + val items: ImmutableList = persistentListOf(), val canLoadMore: Boolean = false, val hasContactsPermission: Boolean = true, val isLoading: Boolean = false, From 068302b486e67716703ca20a1c62a11eba796444 Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Fri, 24 Apr 2026 03:03:22 +0300 Subject: [PATCH 57/99] Audio recording lock --- res/values/strings.xml | 4 + .../ConversationDraftPendingAttachment.kt | 7 + .../conversation/v2/ConversationTestTags.kt | 8 +- .../ConversationAudioRecordingDelegate.kt | 653 ++++++++++++++++-- .../ConversationAudioRecordingUiState.kt | 9 +- ...ConversationComposerAttachmentsDelegate.kt | 4 +- ...ersationComposerAttachmentUiModelMapper.kt | 32 +- .../ConversationComposerUiStateMapper.kt | 5 +- .../model/ComposerAttachmentUiModel.kt | 25 +- .../ui/ConversationAttachmentPreview.kt | 54 +- .../ui/ConversationAudioRecordingBar.kt | 141 +++- .../v2/composer/ui/ConversationComposeBar.kt | 137 +++- .../ui/ConversationComposerSection.kt | 2 + .../ui/ConversationSendActionButton.kt | 242 ++++++- .../review/ConversationMediaPickerReview.kt | 5 +- .../v2/screen/ConversationScreen.kt | 11 +- .../v2/screen/ConversationViewModel.kt | 5 + 17 files changed, 1151 insertions(+), 193 deletions(-) diff --git a/res/values/strings.xml b/res/values/strings.xml index 13b02f0f..e1717eef 100644 --- a/res/values/strings.xml +++ b/res/values/strings.xml @@ -957,6 +957,10 @@ Tap & hold to record audio + + Stop recording and attach audio + + Finalizing audio Start new conversation diff --git a/src/com/android/messaging/data/conversation/model/draft/ConversationDraftPendingAttachment.kt b/src/com/android/messaging/data/conversation/model/draft/ConversationDraftPendingAttachment.kt index 015d9282..322d2228 100644 --- a/src/com/android/messaging/data/conversation/model/draft/ConversationDraftPendingAttachment.kt +++ b/src/com/android/messaging/data/conversation/model/draft/ConversationDraftPendingAttachment.kt @@ -5,4 +5,11 @@ internal data class ConversationDraftPendingAttachment( val contentUri: String, val contentType: String, val displayName: String = "", + val kind: ConversationDraftPendingAttachmentKind = + ConversationDraftPendingAttachmentKind.Generic, ) + +internal enum class ConversationDraftPendingAttachmentKind { + Generic, + AudioFinalizing, +} diff --git a/src/com/android/messaging/ui/conversation/v2/ConversationTestTags.kt b/src/com/android/messaging/ui/conversation/v2/ConversationTestTags.kt index 5726ec18..d9624ac5 100644 --- a/src/com/android/messaging/ui/conversation/v2/ConversationTestTags.kt +++ b/src/com/android/messaging/ui/conversation/v2/ConversationTestTags.kt @@ -29,10 +29,10 @@ internal const val CONVERSATION_INLINE_AUDIO_ATTACHMENT_PROGRESS_TEST_TAG = internal const val CONVERSATION_AUDIO_RECORDING_BAR_TEST_TAG = "conversation_audio_recording_bar" internal const val CONVERSATION_AUDIO_RECORDING_CANCEL_BUTTON_TEST_TAG = "conversation_audio_recording_cancel_button" -internal const val ADD_PARTICIPANTS_CONFIRM_BUTTON_TEST_TAG = - "add_participants_confirm_button" -internal const val NEW_CHAT_CREATE_GROUP_NEXT_BUTTON_TEST_TAG = - "new_chat_create_group_next_button" +internal const val CONVERSATION_AUDIO_RECORDING_LOCK_AFFORDANCE_TEST_TAG = + "conversation_audio_recording_lock_affordance" +internal const val ADD_PARTICIPANTS_CONFIRM_BUTTON_TEST_TAG = "add_participants_confirm_button" +internal const val NEW_CHAT_CREATE_GROUP_NEXT_BUTTON_TEST_TAG = "new_chat_create_group_next_button" internal const val NEW_CHAT_CONTACT_RESOLVING_INDICATOR_TEST_TAG = "new_chat_contact_resolving_indicator" internal const val CONVERSATION_SEND_BUTTON_SHAPE_CIRCLE = "circle" diff --git a/src/com/android/messaging/ui/conversation/v2/audio/delegate/ConversationAudioRecordingDelegate.kt b/src/com/android/messaging/ui/conversation/v2/audio/delegate/ConversationAudioRecordingDelegate.kt index 45980aa6..8d2f202e 100644 --- a/src/com/android/messaging/ui/conversation/v2/audio/delegate/ConversationAudioRecordingDelegate.kt +++ b/src/com/android/messaging/ui/conversation/v2/audio/delegate/ConversationAudioRecordingDelegate.kt @@ -3,8 +3,11 @@ package com.android.messaging.ui.conversation.v2.audio.delegate import android.net.Uri import android.os.SystemClock import com.android.messaging.data.conversation.model.draft.ConversationDraftAttachment +import com.android.messaging.data.conversation.model.draft.ConversationDraftPendingAttachment +import com.android.messaging.data.conversation.model.draft.ConversationDraftPendingAttachmentKind import com.android.messaging.data.conversation.repository.ConversationSubscriptionsRepository import com.android.messaging.di.core.DefaultDispatcher +import com.android.messaging.ui.conversation.v2.audio.model.ConversationAudioRecordingPhase import com.android.messaging.ui.conversation.v2.audio.model.ConversationAudioRecordingUiState import com.android.messaging.ui.conversation.v2.common.ConversationScreenDelegate import com.android.messaging.ui.conversation.v2.composer.delegate.ConversationDraftDelegate @@ -12,24 +15,31 @@ import com.android.messaging.ui.conversation.v2.mediapicker.repository.Conversat import com.android.messaging.ui.mediapicker.LevelTrackingMediaRecorder import com.android.messaging.util.ContentType import com.android.messaging.util.LogUtil -import javax.inject.Inject -import kotlin.time.Duration.Companion.milliseconds import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Job +import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.first +import kotlinx.coroutines.isActive import kotlinx.coroutines.launch +import java.util.UUID +import javax.inject.Inject +import kotlin.time.Duration.Companion.milliseconds internal interface ConversationAudioRecordingDelegate : ConversationScreenDelegate { fun startRecording(selfParticipantId: String) + fun lockRecording(): Boolean + fun finishRecording() fun cancelRecording() @@ -48,11 +58,10 @@ internal class ConversationAudioRecordingDelegateImpl @Inject constructor( private val _state = MutableStateFlow(ConversationAudioRecordingUiState()) override val state = _state.asStateFlow() + private val sessionStateLock = Any() + private var boundScope: CoroutineScope? = null - private var durationJob: Job? = null - private var finishRecordingJob: Job? = null - private var mediaRecorder: LevelTrackingMediaRecorder? = null - private var recordingStartedAtMillis: Long? = null + private var sessionState: AudioRecordingSessionState = AudioRecordingSessionState.Idle override fun bind( scope: CoroutineScope, @@ -64,119 +73,510 @@ internal class ConversationAudioRecordingDelegateImpl @Inject constructor( boundScope = scope scope.launch(defaultDispatcher) { - conversationIdFlow.collect { + conversationIdFlow.drop(count = 1).collect { cancelRecording() } } } override fun startRecording(selfParticipantId: String) { - if (state.value.isRecording) { - return + val scope = boundScope ?: return + val startJob = scope.launch( + context = defaultDispatcher, + start = CoroutineStart.LAZY, + ) { + startRecordingInBackground(selfParticipantId = selfParticipantId) + } + + val shouldStartJob = withSessionStateLock { + tryStartRecordingLocked() } - boundScope?.launch(defaultDispatcher) { - val resolvedMediaRecorder = LevelTrackingMediaRecorder() - val maxMessageSize = conversationSubscriptionsRepository - .resolveMaxMessageSize(selfParticipantId = selfParticipantId) - .first() - val didStartRecording = resolvedMediaRecorder.startRecording( - null, - null, - maxMessageSize, + when { + shouldStartJob -> startJob.start() + else -> startJob.cancel() + + } + } + + override fun lockRecording(): Boolean { + return withSessionStateLock { + tryLockRecordingLocked() + } + } + + override fun finishRecording() { + val scope = boundScope ?: return + val pendingAttachmentId = createPendingAudioAttachmentId() + + val finishJob = scope.launch( + context = defaultDispatcher, + start = CoroutineStart.LAZY, + ) { + finalizeRecording(pendingAttachmentId = pendingAttachmentId) + } + + val effect = withSessionStateLock { + finishRecordingLocked( + pendingAttachmentId = pendingAttachmentId, + finishJob = finishJob, ) + } + + if (effect !is AudioRecordingEffect.StartFinalization) { + finishJob.cancel() + } + + runAudioRecordingEffect( + scope = scope, + effect = effect, + ) + } + + override fun cancelRecording() { + val scope = boundScope ?: return + val effect = withSessionStateLock { + cancelRecordingLocked() + } + + runAudioRecordingEffect( + scope = scope, + effect = effect, + ) + } - if (!didStartRecording) { - return@launch + override fun onScreenCleared() { + cancelRecording() + } + + private fun withSessionStateLock(block: () -> T): T { + return synchronized(sessionStateLock) { + block() + } + } + + private fun tryStartRecordingLocked(): Boolean { + if (sessionState !is AudioRecordingSessionState.Idle) { + return false + } + + sessionState = AudioRecordingSessionState.Starting() + publishUiStateLocked() + return true + } + + private fun tryLockRecordingLocked(): Boolean { + return when (val currentSessionState = sessionState) { + is AudioRecordingSessionState.Starting -> { + lockStartingSessionLocked(currentSessionState) } - mediaRecorder = resolvedMediaRecorder - recordingStartedAtMillis = SystemClock.elapsedRealtime() - _state.value = ConversationAudioRecordingUiState( - isRecording = true, - ) - bindDurationTicker(scope = this) + is AudioRecordingSessionState.Recording -> { + lockActiveSessionLocked(currentSessionState) + } + + else -> false } } - override fun finishRecording() { - if (!state.value.isRecording) { - return + private fun lockStartingSessionLocked( + currentSessionState: AudioRecordingSessionState.Starting, + ): Boolean { + if (currentSessionState.queuedIntent == QueuedStartIntent.Cancel) { + return false } - finishRecordingJob?.cancel() + sessionState = currentSessionState.copy(queuedIntent = QueuedStartIntent.Lock) + publishUiStateLocked() - finishRecordingJob = boundScope?.launch(defaultDispatcher) { - val recordedDurationMillis = when (val startedAtMillis = recordingStartedAtMillis) { - null -> 0L - else -> SystemClock.elapsedRealtime() - startedAtMillis + return true + } + + private fun lockActiveSessionLocked( + currentSessionState: AudioRecordingSessionState.Recording, + ): Boolean { + if (currentSessionState.isLocked) { + return false + } + + sessionState = currentSessionState.copy(isLocked = true) + publishUiStateLocked() + + return true + } + + private fun finishRecordingLocked( + pendingAttachmentId: String, + finishJob: Job, + ): AudioRecordingEffect { + return when (val currentSessionState = sessionState) { + AudioRecordingSessionState.Idle, + is AudioRecordingSessionState.Finalizing, + -> AudioRecordingEffect.None + + is AudioRecordingSessionState.Starting -> { + sessionState = currentSessionState.copy( + queuedIntent = QueuedStartIntent.Cancel, + ) + publishUiStateLocked() + AudioRecordingEffect.None } - if (recordedDurationMillis.milliseconds < audioRecordMinimumDurationMillis) { - deleteStoppedRecording(stopRecording()) - resetRecordingState() - return@launch + is AudioRecordingSessionState.Recording -> { + finishActiveRecordingLocked( + currentSessionState = currentSessionState, + pendingAttachmentId = pendingAttachmentId, + finishJob = finishJob, + ) } + } + } - delay(audioRecordEndingBufferMillis) + private fun finishActiveRecordingLocked( + currentSessionState: AudioRecordingSessionState.Recording, + pendingAttachmentId: String, + finishJob: Job, + ): AudioRecordingEffect { + val recordedDurationMillis = SystemClock.elapsedRealtime() - + currentSessionState.startedAtMillis + + if (recordedDurationMillis.milliseconds < audioRecordMinimumDurationMillis) { + sessionState = AudioRecordingSessionState.Idle + publishUiStateLocked() + + return AudioRecordingEffect.StopAndDeleteRecording( + mediaRecorder = currentSessionState.mediaRecorder, + durationJob = currentSessionState.durationJob, + ) + } - val recordedAttachment = stopRecording()?.let { outputUri -> - ConversationDraftAttachment( - contentType = ContentType.AUDIO_3GPP, - contentUri = outputUri.toString(), + sessionState = AudioRecordingSessionState.Finalizing( + pendingAttachmentId = pendingAttachmentId, + mediaRecorder = currentSessionState.mediaRecorder, + stoppedRecordingUri = null, + durationMillis = currentSessionState.durationMillis, + finishJob = finishJob, + ) + publishUiStateLocked() + + return AudioRecordingEffect.StartFinalization( + finishJob = finishJob, + durationJob = currentSessionState.durationJob, + ) + } + + private fun cancelRecordingLocked(): AudioRecordingEffect { + return when (val currentSessionState = sessionState) { + AudioRecordingSessionState.Idle -> AudioRecordingEffect.None + + is AudioRecordingSessionState.Starting -> { + sessionState = currentSessionState.copy( + queuedIntent = QueuedStartIntent.Cancel, ) + publishUiStateLocked() + + AudioRecordingEffect.None } - recordedAttachment?.let { attachment -> - conversationDraftDelegate.addAttachments( - attachments = listOf(attachment), + is AudioRecordingSessionState.Recording -> { + sessionState = AudioRecordingSessionState.Idle + publishUiStateLocked() + + AudioRecordingEffect.StopAndDeleteRecording( + mediaRecorder = currentSessionState.mediaRecorder, + durationJob = currentSessionState.durationJob, ) } - resetRecordingState() + is AudioRecordingSessionState.Finalizing -> { + sessionState = AudioRecordingSessionState.Idle + publishUiStateLocked() + + AudioRecordingEffect.RemovePendingAndDeleteRecording( + pendingAttachmentId = currentSessionState.pendingAttachmentId, + mediaRecorder = currentSessionState.mediaRecorder, + stoppedRecordingUri = currentSessionState.stoppedRecordingUri, + finishJob = currentSessionState.finishJob, + ) + } } } - override fun cancelRecording() { - if (!state.value.isRecording) { + private fun runAudioRecordingEffect( + scope: CoroutineScope, + effect: AudioRecordingEffect, + ) { + when (effect) { + AudioRecordingEffect.None -> Unit + + is AudioRecordingEffect.StartFinalization -> { + effect.durationJob.cancel() + effect.finishJob.start() + } + + is AudioRecordingEffect.StopAndDeleteRecording -> { + effect.durationJob?.cancel() + + scope.launch(defaultDispatcher) { + val outputUri = stopRecording(mediaRecorder = effect.mediaRecorder) + deleteStoppedRecording(outputUri = outputUri) + } + } + + is AudioRecordingEffect.RemovePendingAndDeleteRecording -> { + effect.finishJob.cancel() + + scope.launch(defaultDispatcher) { + conversationDraftDelegate.removePendingAttachment( + pendingAttachmentId = effect.pendingAttachmentId, + ) + val outputUri = effect.stoppedRecordingUri + ?: effect.mediaRecorder?.let { mediaRecorder -> + stopRecording(mediaRecorder = mediaRecorder) + } + deleteStoppedRecording(outputUri = outputUri) + } + } + } + } + + private suspend fun startRecordingInBackground(selfParticipantId: String) { + val resolvedMediaRecorder = LevelTrackingMediaRecorder() + val maxMessageSize = conversationSubscriptionsRepository + .resolveMaxMessageSize(selfParticipantId = selfParticipantId) + .first() + + val didStartRecording = resolvedMediaRecorder.startRecording( + null, + null, + maxMessageSize, + ) + + if (!didStartRecording) { + withSessionStateLock { + clearStartingSessionLocked() + } + return } - boundScope?.launch(defaultDispatcher) { - finishRecordingJob?.cancel() - deleteStoppedRecording(stopRecording()) - resetRecordingState() + val startedAtMillis = SystemClock.elapsedRealtime() + val durationJob = boundScope?.launch(defaultDispatcher) { + bindDurationTicker(startedAtMillis = startedAtMillis) + } + + val effect = withSessionStateLock { + completeRecorderStartLocked( + mediaRecorder = resolvedMediaRecorder, + startedAtMillis = startedAtMillis, + durationJob = durationJob, + ) } + + runAudioRecordingEffect( + scope = requireNotNull(boundScope) { + "Bound scope must be available while recording starts" + }, + effect = effect, + ) } - override fun onScreenCleared() { - cancelRecording() + private fun clearStartingSessionLocked() { + if (sessionState is AudioRecordingSessionState.Starting) { + sessionState = AudioRecordingSessionState.Idle + publishUiStateLocked() + } } - private fun bindDurationTicker(scope: CoroutineScope) { - durationJob?.cancel() - durationJob = scope.launch(defaultDispatcher) { - while (state.value.isRecording) { - val resolvedStartMillis = recordingStartedAtMillis ?: break - _state.value = ConversationAudioRecordingUiState( - isRecording = true, - durationMillis = SystemClock.elapsedRealtime() - resolvedStartMillis, + private fun completeRecorderStartLocked( + mediaRecorder: LevelTrackingMediaRecorder, + startedAtMillis: Long, + durationJob: Job?, + ): AudioRecordingEffect { + val currentSessionState = sessionState as? AudioRecordingSessionState.Starting + ?: return AudioRecordingEffect.StopAndDeleteRecording( + mediaRecorder = mediaRecorder, + durationJob = durationJob, + ) + + if (currentSessionState.queuedIntent == QueuedStartIntent.Cancel) { + sessionState = AudioRecordingSessionState.Idle + publishUiStateLocked() + + return AudioRecordingEffect.StopAndDeleteRecording( + mediaRecorder = mediaRecorder, + durationJob = durationJob, + ) + } + + sessionState = AudioRecordingSessionState.Recording( + mediaRecorder = mediaRecorder, + startedAtMillis = startedAtMillis, + durationMillis = 0L, + isLocked = currentSessionState.queuedIntent == QueuedStartIntent.Lock, + durationJob = requireNotNull(durationJob) { + "Duration job must be available for active recording" + }, + ) + + publishUiStateLocked() + + return AudioRecordingEffect.None + } + + private suspend fun finalizeRecording(pendingAttachmentId: String) { + addPendingAudioAttachment(pendingAttachmentId = pendingAttachmentId) + delay(audioRecordEndingBufferMillis) + + val mediaRecorder = withSessionStateLock { + claimFinalizingRecorderLocked(pendingAttachmentId = pendingAttachmentId) + } + + val outputUri = mediaRecorder?.let { finalizingMediaRecorder -> + stopRecording(mediaRecorder = finalizingMediaRecorder) + } + + val shouldResolvePendingAttachment = withSessionStateLock { + storeStoppedRecordingUriLocked( + pendingAttachmentId = pendingAttachmentId, + outputUri = outputUri, + ) + } + + if (!shouldResolvePendingAttachment || !currentCoroutineContext().isActive) { + deleteStoppedRecording(outputUri = outputUri) + return + } + + resolvePendingAudioAttachment( + pendingAttachmentId = pendingAttachmentId, + outputUri = outputUri, + ) + + withSessionStateLock { + clearFinalizingSessionLocked(pendingAttachmentId = pendingAttachmentId) + } + } + + private fun claimFinalizingRecorderLocked( + pendingAttachmentId: String, + ): LevelTrackingMediaRecorder? { + val currentSessionState = sessionState as? AudioRecordingSessionState.Finalizing + ?: return null + + if (currentSessionState.pendingAttachmentId != pendingAttachmentId) { + return null + } + + sessionState = currentSessionState.copy(mediaRecorder = null) + + return currentSessionState.mediaRecorder + } + + private fun storeStoppedRecordingUriLocked( + pendingAttachmentId: String, + outputUri: Uri?, + ): Boolean { + val currentSessionState = sessionState as? AudioRecordingSessionState.Finalizing + ?: return false + + if (currentSessionState.pendingAttachmentId != pendingAttachmentId) { + return false + } + + sessionState = currentSessionState.copy(stoppedRecordingUri = outputUri) + + return true + } + + private fun clearFinalizingSessionLocked(pendingAttachmentId: String) { + val currentSessionState = sessionState as? AudioRecordingSessionState.Finalizing + ?: return + + if (currentSessionState.pendingAttachmentId != pendingAttachmentId) { + return + } + + sessionState = AudioRecordingSessionState.Idle + publishUiStateLocked() + } + + private fun addPendingAudioAttachment(pendingAttachmentId: String) { + conversationDraftDelegate.addPendingAttachment( + pendingAttachment = ConversationDraftPendingAttachment( + pendingAttachmentId = pendingAttachmentId, + contentUri = createPendingAudioAttachmentUri( + pendingAttachmentId = pendingAttachmentId, + ), + contentType = ContentType.AUDIO_3GPP, + kind = ConversationDraftPendingAttachmentKind.AudioFinalizing, + ), + ) + } + + private fun resolvePendingAudioAttachment( + pendingAttachmentId: String, + outputUri: Uri?, + ) { + val recordedAttachment = outputUri?.let { resolvedOutputUri -> + ConversationDraftAttachment( + contentType = ContentType.AUDIO_3GPP, + contentUri = resolvedOutputUri.toString(), + ) + } + + when (recordedAttachment) { + null -> { + conversationDraftDelegate.removePendingAttachment( + pendingAttachmentId = pendingAttachmentId, + ) + } + + else -> { + conversationDraftDelegate.resolvePendingAttachment( + pendingAttachmentId = pendingAttachmentId, + attachment = recordedAttachment, ) - delay(durationTickIntervalMillis) } } } - private fun stopRecording(): Uri? { - val resolvedMediaRecorder = mediaRecorder ?: return null + private suspend fun bindDurationTicker(startedAtMillis: Long) { + while (true) { + val shouldContinue = withSessionStateLock { + tickRecordingDurationLocked(startedAtMillis = startedAtMillis) + } + + if (!shouldContinue) { + return + } + + delay(durationTickIntervalMillis) + } + } + + private fun tickRecordingDurationLocked(startedAtMillis: Long): Boolean { + val currentSessionState = sessionState as? AudioRecordingSessionState.Recording + ?: return false + if (currentSessionState.startedAtMillis != startedAtMillis) { + return false + } + + sessionState = currentSessionState.copy( + durationMillis = SystemClock.elapsedRealtime() - startedAtMillis, + ) + publishUiStateLocked() + + return true + } + + private fun stopRecording(mediaRecorder: LevelTrackingMediaRecorder): Uri? { return try { - resolvedMediaRecorder.stopRecording() + mediaRecorder.stopRecording() } catch (throwable: Throwable) { LogUtil.w(TAG, "Failed to stop audio recording", throwable) null - } finally { - mediaRecorder = null } } @@ -190,17 +590,118 @@ internal class ConversationAudioRecordingDelegateImpl @Inject constructor( .collect() } - private fun resetRecordingState() { - finishRecordingJob = null - durationJob?.cancel() - durationJob = null - mediaRecorder = null - recordingStartedAtMillis = null - _state.value = ConversationAudioRecordingUiState() + private fun publishUiStateLocked() { + _state.value = createUiState(sessionState = sessionState) + } + + private fun createUiState( + sessionState: AudioRecordingSessionState, + ): ConversationAudioRecordingUiState { + return when (sessionState) { + AudioRecordingSessionState.Idle -> ConversationAudioRecordingUiState() + + is AudioRecordingSessionState.Starting -> { + createStartingUiState( + queuedIntent = sessionState.queuedIntent, + ) + } + + is AudioRecordingSessionState.Recording -> { + ConversationAudioRecordingUiState( + phase = ConversationAudioRecordingPhase.Recording, + durationMillis = sessionState.durationMillis, + isLocked = sessionState.isLocked, + ) + } + + is AudioRecordingSessionState.Finalizing -> { + ConversationAudioRecordingUiState( + phase = ConversationAudioRecordingPhase.Finalizing, + durationMillis = sessionState.durationMillis, + ) + } + } + } + + private fun createStartingUiState( + queuedIntent: QueuedStartIntent, + ): ConversationAudioRecordingUiState { + return when { + queuedIntent == QueuedStartIntent.Cancel -> { + ConversationAudioRecordingUiState() + } + + else -> { + ConversationAudioRecordingUiState( + phase = ConversationAudioRecordingPhase.Recording, + isLocked = queuedIntent == QueuedStartIntent.Lock, + ) + } + } + } + + private fun createPendingAudioAttachmentId(): String { + return "pending-audio-${UUID.randomUUID()}" + } + + private fun createPendingAudioAttachmentUri(pendingAttachmentId: String): String { + return "${PENDING_AUDIO_URI_PREFIX}$pendingAttachmentId" + } + + private sealed interface AudioRecordingSessionState { + data object Idle : AudioRecordingSessionState + + data class Starting( + val queuedIntent: QueuedStartIntent = QueuedStartIntent.None, + ) : AudioRecordingSessionState + + data class Recording( + val mediaRecorder: LevelTrackingMediaRecorder, + val startedAtMillis: Long, + val durationMillis: Long, + val isLocked: Boolean, + val durationJob: Job, + ) : AudioRecordingSessionState + + data class Finalizing( + val pendingAttachmentId: String, + val mediaRecorder: LevelTrackingMediaRecorder?, + val stoppedRecordingUri: Uri?, + val durationMillis: Long, + val finishJob: Job, + ) : AudioRecordingSessionState + } + + private enum class QueuedStartIntent { + None, + Lock, + Cancel, + } + + private sealed interface AudioRecordingEffect { + data object None : AudioRecordingEffect + + data class StartFinalization( + val finishJob: Job, + val durationJob: Job, + ) : AudioRecordingEffect + + data class StopAndDeleteRecording( + val mediaRecorder: LevelTrackingMediaRecorder, + val durationJob: Job?, + ) : AudioRecordingEffect + + data class RemovePendingAndDeleteRecording( + val pendingAttachmentId: String, + val mediaRecorder: LevelTrackingMediaRecorder?, + val stoppedRecordingUri: Uri?, + val finishJob: Job, + ) : AudioRecordingEffect } private companion object { private const val TAG = "ConversationAudioRecording" + private const val PENDING_AUDIO_URI_PREFIX = "pending://audio/" private val audioRecordEndingBufferMillis = 500L.milliseconds private val audioRecordMinimumDurationMillis = 300L.milliseconds diff --git a/src/com/android/messaging/ui/conversation/v2/audio/model/ConversationAudioRecordingUiState.kt b/src/com/android/messaging/ui/conversation/v2/audio/model/ConversationAudioRecordingUiState.kt index 86726bc4..4a29c516 100644 --- a/src/com/android/messaging/ui/conversation/v2/audio/model/ConversationAudioRecordingUiState.kt +++ b/src/com/android/messaging/ui/conversation/v2/audio/model/ConversationAudioRecordingUiState.kt @@ -2,8 +2,15 @@ package com.android.messaging.ui.conversation.v2.audio.model import androidx.compose.runtime.Immutable +internal enum class ConversationAudioRecordingPhase { + Idle, + Recording, + Finalizing, +} + @Immutable internal data class ConversationAudioRecordingUiState( - val isRecording: Boolean = false, + val phase: ConversationAudioRecordingPhase = ConversationAudioRecordingPhase.Idle, val durationMillis: Long = 0L, + val isLocked: Boolean = false, ) diff --git a/src/com/android/messaging/ui/conversation/v2/composer/delegate/ConversationComposerAttachmentsDelegate.kt b/src/com/android/messaging/ui/conversation/v2/composer/delegate/ConversationComposerAttachmentsDelegate.kt index 7ba802df..80f4c680 100644 --- a/src/com/android/messaging/ui/conversation/v2/composer/delegate/ConversationComposerAttachmentsDelegate.kt +++ b/src/com/android/messaging/ui/conversation/v2/composer/delegate/ConversationComposerAttachmentsDelegate.kt @@ -160,7 +160,9 @@ internal class ConversationComposerAttachmentsDelegateImpl @Inject constructor( vCardAttachmentMetadata: Map, ): ComposerAttachmentUiModel { return when (attachment) { - is ComposerAttachmentUiModel.Pending -> { + is ComposerAttachmentUiModel.Pending.AudioFinalizing, + is ComposerAttachmentUiModel.Pending.Generic, + -> { attachment } diff --git a/src/com/android/messaging/ui/conversation/v2/composer/mapper/ConversationComposerAttachmentUiModelMapper.kt b/src/com/android/messaging/ui/conversation/v2/composer/mapper/ConversationComposerAttachmentUiModelMapper.kt index b574b94d..33173759 100644 --- a/src/com/android/messaging/ui/conversation/v2/composer/mapper/ConversationComposerAttachmentUiModelMapper.kt +++ b/src/com/android/messaging/ui/conversation/v2/composer/mapper/ConversationComposerAttachmentUiModelMapper.kt @@ -2,6 +2,7 @@ package com.android.messaging.ui.conversation.v2.composer.mapper import com.android.messaging.data.conversation.model.draft.ConversationDraftAttachment import com.android.messaging.data.conversation.model.draft.ConversationDraftPendingAttachment +import com.android.messaging.data.conversation.model.draft.ConversationDraftPendingAttachmentKind import com.android.messaging.ui.conversation.v2.composer.model.ComposerAttachmentUiModel import com.android.messaging.ui.conversation.v2.messages.mapper.ConversationVCardAttachmentUiModelMapper import com.android.messaging.ui.conversation.v2.messages.model.attachment.ConversationVCardAttachmentMetadata @@ -31,17 +32,38 @@ internal class ConversationComposerAttachmentUiModelMapperImpl @Inject construct ) } val pendingAttachmentUiModels = pendingAttachments.map { pendingAttachment -> - ComposerAttachmentUiModel.Pending( - key = pendingAttachment.pendingAttachmentId, - contentType = pendingAttachment.contentType, - contentUri = pendingAttachment.contentUri, - displayName = pendingAttachment.displayName, + createPendingAttachmentUiModel( + pendingAttachment = pendingAttachment, ) } return (resolvedAttachments + pendingAttachmentUiModels).toImmutableList() } + private fun createPendingAttachmentUiModel( + pendingAttachment: ConversationDraftPendingAttachment, + ): ComposerAttachmentUiModel.Pending { + return when (pendingAttachment.kind) { + ConversationDraftPendingAttachmentKind.Generic -> { + ComposerAttachmentUiModel.Pending.Generic( + key = pendingAttachment.pendingAttachmentId, + contentType = pendingAttachment.contentType, + contentUri = pendingAttachment.contentUri, + displayName = pendingAttachment.displayName, + ) + } + + ConversationDraftPendingAttachmentKind.AudioFinalizing -> { + ComposerAttachmentUiModel.Pending.AudioFinalizing( + key = pendingAttachment.pendingAttachmentId, + contentType = pendingAttachment.contentType, + contentUri = pendingAttachment.contentUri, + displayName = pendingAttachment.displayName, + ) + } + } + } + private fun createResolvedAttachmentUiModel( attachment: ConversationDraftAttachment, ): ComposerAttachmentUiModel.Resolved { diff --git a/src/com/android/messaging/ui/conversation/v2/composer/mapper/ConversationComposerUiStateMapper.kt b/src/com/android/messaging/ui/conversation/v2/composer/mapper/ConversationComposerUiStateMapper.kt index 0467f2e0..158dd031 100644 --- a/src/com/android/messaging/ui/conversation/v2/composer/mapper/ConversationComposerUiStateMapper.kt +++ b/src/com/android/messaging/ui/conversation/v2/composer/mapper/ConversationComposerUiStateMapper.kt @@ -2,6 +2,7 @@ package com.android.messaging.ui.conversation.v2.composer.mapper import com.android.messaging.data.conversation.model.metadata.ConversationComposerAvailability import com.android.messaging.data.conversation.model.metadata.ConversationSubscription +import com.android.messaging.ui.conversation.v2.audio.model.ConversationAudioRecordingPhase import com.android.messaging.ui.conversation.v2.audio.model.ConversationAudioRecordingUiState import com.android.messaging.ui.conversation.v2.composer.model.ComposerAttachmentUiModel import com.android.messaging.ui.conversation.v2.composer.model.ConversationComposerUiState @@ -38,7 +39,9 @@ internal class ConversationComposerUiStateMapperImpl @Inject constructor() : !draft.isSending val isMessageFieldEnabled = composerAvailability.isMessageFieldEnabled - val shouldShowRecordAction = !hasWorkingDraft && !audioRecording.isRecording + val shouldShowRecordAction = !hasWorkingDraft && + audioRecording.phase == ConversationAudioRecordingPhase.Idle + val isRecordActionEnabled = composerAvailability.isSendAvailable && !draft.isCheckingDraft && !draft.isSending && diff --git a/src/com/android/messaging/ui/conversation/v2/composer/model/ComposerAttachmentUiModel.kt b/src/com/android/messaging/ui/conversation/v2/composer/model/ComposerAttachmentUiModel.kt index fdffc470..c2df31db 100644 --- a/src/com/android/messaging/ui/conversation/v2/composer/model/ComposerAttachmentUiModel.kt +++ b/src/com/android/messaging/ui/conversation/v2/composer/model/ComposerAttachmentUiModel.kt @@ -10,12 +10,25 @@ internal sealed interface ComposerAttachmentUiModel { val contentUri: String @Immutable - data class Pending( - override val key: String, - override val contentType: String, - override val contentUri: String, - val displayName: String, - ) : ComposerAttachmentUiModel + sealed interface Pending : ComposerAttachmentUiModel { + val displayName: String + + @Immutable + data class Generic( + override val key: String, + override val contentType: String, + override val contentUri: String, + override val displayName: String, + ) : Pending + + @Immutable + data class AudioFinalizing( + override val key: String, + override val contentType: String, + override val contentUri: String, + override val displayName: String, + ) : Pending + } @Immutable sealed interface Resolved : ComposerAttachmentUiModel { diff --git a/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationAttachmentPreview.kt b/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationAttachmentPreview.kt index 21637d30..0a685f45 100644 --- a/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationAttachmentPreview.kt +++ b/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationAttachmentPreview.kt @@ -34,6 +34,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import com.android.messaging.R @@ -81,7 +82,13 @@ internal fun ConversationAttachmentPreview( key = { attachment -> attachment.key }, ) { attachment -> when (attachment) { - is ComposerAttachmentUiModel.Pending -> { + is ComposerAttachmentUiModel.Pending.AudioFinalizing -> { + PendingAudioAttachmentPreviewItem( + attachmentKey = attachment.key, + ) + } + + is ComposerAttachmentUiModel.Pending.Generic -> { PendingAttachmentPreviewItem( attachmentKey = attachment.key, onRemoveClick = { @@ -142,6 +149,51 @@ private fun PendingAttachmentPreviewItem( } } +@Composable +private fun PendingAudioAttachmentPreviewItem( + attachmentKey: String, +) { + AttachmentPreviewItemContainer( + modifier = Modifier + .height(height = ATTACHMENT_PREVIEW_CARD_HEIGHT) + .wrapContentWidth(), + attachmentKey = attachmentKey, + onClick = {}, + ) { + Row( + modifier = Modifier + .wrapContentWidth() + .height(height = ATTACHMENT_PREVIEW_CARD_HEIGHT) + .padding(horizontal = 16.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier + .size(40.dp) + .clip(shape = RoundedCornerShape(size = 20.dp)) + .background(color = MaterialTheme.colorScheme.primaryContainer), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator( + modifier = Modifier + .size(20.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onPrimaryContainer, + ) + } + + Text( + modifier = Modifier.wrapContentWidth(), + text = stringResource(id = R.string.audio_recording_finalizing_attachment_label), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + ) + } + } +} + @Composable private fun ResolvedAttachmentPreviewItem( attachment: ComposerAttachmentUiModel.Resolved, diff --git a/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationAudioRecordingBar.kt b/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationAudioRecordingBar.kt index efd2d5df..bd56a80f 100644 --- a/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationAudioRecordingBar.kt +++ b/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationAudioRecordingBar.kt @@ -16,6 +16,8 @@ import androidx.compose.animation.shrinkHorizontally import androidx.compose.animation.slideInHorizontally import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth @@ -23,16 +25,20 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.ArrowBack import androidx.compose.material.icons.rounded.DeleteOutline +import androidx.compose.material.icons.rounded.KeyboardArrowUp +import androidx.compose.material.icons.rounded.Lock import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.shadow import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.lerp @@ -42,6 +48,7 @@ import androidx.compose.ui.unit.dp import com.android.messaging.R import com.android.messaging.ui.conversation.v2.CONVERSATION_AUDIO_RECORDING_BAR_TEST_TAG import com.android.messaging.ui.conversation.v2.CONVERSATION_AUDIO_RECORDING_CANCEL_BUTTON_TEST_TAG +import com.android.messaging.ui.conversation.v2.CONVERSATION_AUDIO_RECORDING_LOCK_AFFORDANCE_TEST_TAG import com.android.messaging.ui.conversation.v2.audio.formatConversationAudioDuration private const val AUDIO_RECORDING_COLOR_ANIMATION_THRESHOLD = 0.7f @@ -58,39 +65,45 @@ internal fun ConversationAudioRecordingBar( isCancellationArmed = isCancellationArmed, ) - Row( + Box( modifier = modifier .fillMaxWidth() .height(height = 56.dp) - .background( - color = MaterialTheme.colorScheme.surfaceContainerHigh, - shape = RoundedCornerShape(size = 28.dp), - ) - .padding( - horizontal = 12.dp, - ) .testTag(CONVERSATION_AUDIO_RECORDING_BAR_TEST_TAG), - verticalAlignment = Alignment.CenterVertically, ) { - AudioRecordingDeleteIcon( - isVisible = isCancellationArmed, - tint = visualState.deleteIconTint, - ) + Row( + modifier = Modifier + .fillMaxWidth() + .height(height = 56.dp) + .background( + color = MaterialTheme.colorScheme.surfaceContainerHigh, + shape = RoundedCornerShape(size = 28.dp), + ) + .padding( + horizontal = 12.dp, + ), + verticalAlignment = Alignment.CenterVertically, + ) { + AudioRecordingDeleteIcon( + isVisible = isCancellationArmed, + tint = visualState.deleteIconTint, + ) - Spacer(modifier = Modifier.width(width = 4.dp)) + Spacer(modifier = Modifier.width(width = 4.dp)) - AudioRecordingDurationLabel( - durationMillis = durationMillis, - contentColor = visualState.contentColor, - ) + AudioRecordingDurationLabel( + durationMillis = durationMillis, + contentColor = visualState.contentColor, + ) - AudioRecordingCancelHint( - modifier = Modifier - .weight(weight = 1f) - .padding(end = 8.dp), - contentColor = visualState.contentColor, - hintAlpha = visualState.hintAlpha, - ) + AudioRecordingCancelHint( + modifier = Modifier + .weight(weight = 1f) + .padding(end = 8.dp), + contentColor = visualState.contentColor, + hintAlpha = visualState.hintAlpha, + ) + } } } @@ -284,6 +297,84 @@ private fun RecordingIndicatorDot() { ) } +@Composable +internal fun ConversationAudioRecordingLockAffordance( + modifier: Modifier = Modifier, + lockProgress: Float, +) { + val resolvedLockProgress = lockProgress.coerceIn(minimumValue = 0f, maximumValue = 1f) + + val contentColor = animateColorAsState( + targetValue = lerp( + start = MaterialTheme.colorScheme.onSurfaceVariant, + stop = MaterialTheme.colorScheme.onSurface, + fraction = resolvedLockProgress, + ), + animationSpec = tween(durationMillis = 180), + label = "conversation_audio_lock_content_color", + ).value + + val affordanceScale = animateFloatAsState( + targetValue = 0.96f + (resolvedLockProgress * 0.06f), + animationSpec = tween(durationMillis = 180), + label = "conversation_audio_lock_scale", + ).value + + val verticalTranslation = -8f * resolvedLockProgress + + Column( + modifier = modifier + .graphicsLayer { + scaleX = affordanceScale + scaleY = affordanceScale + translationY = verticalTranslation + } + .shadow( + elevation = 8.dp, + shape = RoundedCornerShape(size = 24.dp), + ) + .background( + color = MaterialTheme.colorScheme.surface, + shape = RoundedCornerShape(size = 24.dp), + ) + .padding( + paddingValues = PaddingValues( + horizontal = 10.dp, + vertical = 8.dp, + ), + ) + .testTag(CONVERSATION_AUDIO_RECORDING_LOCK_AFFORDANCE_TEST_TAG), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + modifier = Modifier.size(size = 18.dp), + imageVector = Icons.Rounded.Lock, + contentDescription = null, + tint = contentColor, + ) + + Spacer( + modifier = Modifier + .padding(vertical = 4.dp) + .size( + width = 18.dp, + height = 1.dp, + ) + .background( + color = contentColor.copy(alpha = 0.2f), + shape = CircleShape, + ), + ) + + Icon( + modifier = Modifier.size(size = 18.dp), + imageVector = Icons.Rounded.KeyboardArrowUp, + contentDescription = null, + tint = contentColor, + ) + } +} + private data class AudioRecordingBarVisualState( val contentColor: Color, val deleteIconTint: Color, diff --git a/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationComposeBar.kt b/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationComposeBar.kt index 4d333d4a..0233cd1e 100644 --- a/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationComposeBar.kt +++ b/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationComposeBar.kt @@ -16,6 +16,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons @@ -34,7 +35,6 @@ import androidx.compose.material3.TextFieldDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable @@ -60,11 +60,13 @@ import com.android.messaging.ui.conversation.v2.CONVERSATION_COMPOSE_BAR_TEST_TA import com.android.messaging.ui.conversation.v2.CONVERSATION_SEND_BUTTON_SHAPE_CIRCLE import com.android.messaging.ui.conversation.v2.CONVERSATION_SEND_BUTTON_TEST_TAG import com.android.messaging.ui.conversation.v2.CONVERSATION_TEXT_FIELD_TEST_TAG +import com.android.messaging.ui.conversation.v2.audio.model.ConversationAudioRecordingPhase import com.android.messaging.ui.conversation.v2.audio.model.ConversationAudioRecordingUiState import com.android.messaging.ui.conversation.v2.conversationShape import com.android.messaging.ui.core.AppTheme -private val AUDIO_RECORD_CANCEL_THRESHOLD = 96.dp +internal val AUDIO_RECORD_CANCEL_THRESHOLD = 96.dp +internal val AUDIO_RECORD_LOCK_THRESHOLD = 72.dp @Composable internal fun ConversationComposeBar( @@ -82,18 +84,20 @@ internal fun ConversationComposeBar( onMessageTextChange: (String) -> Unit, onAudioRecordingStartRequest: () -> Unit, onAudioRecordingFinish: () -> Unit, + onAudioRecordingLock: () -> Boolean, onAudioRecordingCancel: () -> Unit, onSendClick: () -> Unit, ) { val presentation = rememberConversationComposeBarPresentation() + val hapticFeedback = LocalHapticFeedback.current - var recordingDragDistancePx by remember { - mutableFloatStateOf(value = 0f) + var recordingGestureState by remember { + mutableStateOf(ConversationSendActionButtonGestureState()) } - LaunchedEffect(audioRecording.isRecording) { - if (!audioRecording.isRecording) { - recordingDragDistancePx = 0f + LaunchedEffect(audioRecording.phase) { + if (audioRecording.phase != ConversationAudioRecordingPhase.Recording) { + recordingGestureState = ConversationSendActionButtonGestureState() } } @@ -112,21 +116,33 @@ internal fun ConversationComposeBar( isRecordActionEnabled = isRecordActionEnabled, isSendActionEnabled = isSendActionEnabled, shouldShowRecordAction = shouldShowRecordAction, - recordingDragDistancePx = recordingDragDistancePx, + recordingGestureState = recordingGestureState, messageFieldFocusRequester = messageFieldFocusRequester, presentation = presentation, onContactAttachClick = onContactAttachClick, onMediaPickerClick = onMediaPickerClick, onMessageTextChange = onMessageTextChange, onAudioRecordingStartRequest = { - recordingDragDistancePx = 0f + recordingGestureState = ConversationSendActionButtonGestureState() onAudioRecordingStartRequest() }, - onAudioRecordingDrag = { dragDistancePx -> - recordingDragDistancePx = dragDistancePx + onAudioRecordingDrag = { gestureState -> + recordingGestureState = gestureState + }, + onAudioRecordingLock = { + if (audioRecording.isLocked) { + return@ConversationComposeInputContent false + } + + recordingGestureState = ConversationSendActionButtonGestureState() + val didLockRecording = onAudioRecordingLock() + if (didLockRecording) { + hapticFeedback.performHapticFeedback(HapticFeedbackType.Confirm) + } + didLockRecording }, onAudioRecordingFinish = { shouldCancelRecording -> - recordingDragDistancePx = 0f + recordingGestureState = ConversationSendActionButtonGestureState() when { shouldCancelRecording -> onAudioRecordingCancel() else -> onAudioRecordingFinish() @@ -182,25 +198,44 @@ private fun ConversationComposeInputContent( isRecordActionEnabled: Boolean, isSendActionEnabled: Boolean, shouldShowRecordAction: Boolean, - recordingDragDistancePx: Float, + recordingGestureState: ConversationSendActionButtonGestureState, messageFieldFocusRequester: FocusRequester?, presentation: ConversationComposeBarPresentation, onContactAttachClick: () -> Unit, onMediaPickerClick: () -> Unit, onMessageTextChange: (String) -> Unit, onAudioRecordingStartRequest: () -> Unit, - onAudioRecordingDrag: (Float) -> Unit, + onAudioRecordingDrag: (ConversationSendActionButtonGestureState) -> Unit, + onAudioRecordingLock: () -> Boolean, onAudioRecordingFinish: (Boolean) -> Unit, onSendClick: () -> Unit, ) { val cancelThresholdPx = with(LocalDensity.current) { AUDIO_RECORD_CANCEL_THRESHOLD.toPx() } - val cancelProgress = (recordingDragDistancePx / cancelThresholdPx) + val lockThresholdPx = with(LocalDensity.current) { + AUDIO_RECORD_LOCK_THRESHOLD.toPx() + } + val cancelProgress = (recordingGestureState.cancelDragDistancePx / cancelThresholdPx) .coerceIn(minimumValue = 0f, maximumValue = 1f) + val lockProgress = when { + audioRecording.isLocked -> 1f + + else -> { + (recordingGestureState.lockDragDistancePx / lockThresholdPx) + .coerceIn(minimumValue = 0f, maximumValue = 1f) + } + } + val isCancellationArmed = cancelProgress >= 1f - val isRecordMode = shouldShowRecordAction || audioRecording.isRecording + val isActiveRecording = audioRecording.phase == ConversationAudioRecordingPhase.Recording + val isRecordMode = shouldShowRecordAction || isActiveRecording + val isRecordingControlEnabled = when { + isActiveRecording -> true + isRecordMode -> isRecordActionEnabled + else -> isSendActionEnabled + } Row( modifier = Modifier @@ -215,9 +250,8 @@ private fun ConversationComposeInputContent( verticalAlignment = Alignment.Bottom, ) { AnimatedContent( - modifier = Modifier - .weight(weight = 1f), - targetState = audioRecording.isRecording, + modifier = Modifier.weight(weight = 1f), + targetState = isActiveRecording, transitionSpec = { contentSwapTransition() }, @@ -254,18 +288,23 @@ private fun ConversationComposeInputContent( .semantics { conversationShape = CONVERSATION_SEND_BUTTON_SHAPE_CIRCLE }, - enabled = when { - isRecordMode -> isRecordActionEnabled - else -> isSendActionEnabled - }, + enabled = isRecordingControlEnabled, mode = when { + isRecordMode && audioRecording.isLocked -> ConversationSendActionButtonMode.Stop isRecordMode -> ConversationSendActionButtonMode.Record else -> ConversationSendActionButtonMode.Send }, - isRecordingActive = audioRecording.isRecording, + isRecordingActive = isActiveRecording, + isRecordingLocked = audioRecording.isLocked, + shouldShowLockAffordance = isActiveRecording && !audioRecording.isLocked, + lockProgress = lockProgress, onClick = onSendClick, + onLockedStopClick = { + onAudioRecordingFinish(false) + }, onRecordGestureStart = onAudioRecordingStartRequest, onRecordGestureMove = onAudioRecordingDrag, + onRecordGestureLock = onAudioRecordingLock, onRecordGestureFinish = onAudioRecordingFinish, ) } @@ -300,8 +339,7 @@ private fun ConversationComposeMessageField( placeholder = ::ConversationComposePlaceholder, leadingIcon = { ConversationComposeAttachmentMenu( - modifier = Modifier - .testTag(CONVERSATION_ATTACHMENT_BUTTON_TEST_TAG), + modifier = Modifier.testTag(CONVERSATION_ATTACHMENT_BUTTON_TEST_TAG), enabled = isAttachmentActionEnabled, onContactAttachClick = onContactAttachClick, onMediaPickerClick = onMediaPickerClick, @@ -423,21 +461,46 @@ private fun ConversationComposeSendAction( enabled: Boolean, mode: ConversationSendActionButtonMode, isRecordingActive: Boolean, + isRecordingLocked: Boolean, + shouldShowLockAffordance: Boolean, + lockProgress: Float, onClick: () -> Unit, + onLockedStopClick: () -> Unit, onRecordGestureStart: () -> Unit, - onRecordGestureMove: (Float) -> Unit, + onRecordGestureMove: (ConversationSendActionButtonGestureState) -> Unit, + onRecordGestureLock: () -> Boolean, onRecordGestureFinish: (Boolean) -> Unit, ) { - ConversationSendActionButton( - modifier = modifier, - enabled = enabled, - mode = mode, - isRecordingActive = isRecordingActive, - onClick = onClick, - onRecordGestureStart = onRecordGestureStart, - onRecordGestureMove = onRecordGestureMove, - onRecordGestureFinish = onRecordGestureFinish, - ) + Box( + modifier = Modifier.heightIn( + min = 56.dp, + max = 56.dp, + ), + ) { + ConversationSendActionButton( + modifier = modifier, + enabled = enabled, + mode = mode, + isRecordingActive = isRecordingActive, + isRecordingLocked = isRecordingLocked, + onClick = onClick, + onLockedStopClick = onLockedStopClick, + onRecordGestureStart = onRecordGestureStart, + onRecordGestureMove = onRecordGestureMove, + onRecordGestureLock = onRecordGestureLock, + onRecordGestureFinish = onRecordGestureFinish, + ) + + if (shouldShowLockAffordance) { + ConversationAudioRecordingLockAffordance( + modifier = Modifier + .align(alignment = Alignment.TopCenter) + .padding(top = 2.dp) + .offset(y = (-74).dp), + lockProgress = lockProgress, + ) + } + } } private data class ConversationComposeBarPresentation( diff --git a/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationComposerSection.kt b/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationComposerSection.kt index 10c66543..eea9972f 100644 --- a/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationComposerSection.kt +++ b/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationComposerSection.kt @@ -28,6 +28,7 @@ internal fun ConversationComposerSection( onResolvedAttachmentRemove: (String) -> Unit, onAudioRecordingStartRequest: () -> Unit, onAudioRecordingFinish: () -> Unit, + onAudioRecordingLock: () -> Boolean, onAudioRecordingCancel: () -> Unit, onSendClick: () -> Unit, ) { @@ -55,6 +56,7 @@ internal fun ConversationComposerSection( onMessageTextChange = onMessageTextChange, onAudioRecordingStartRequest = onAudioRecordingStartRequest, onAudioRecordingFinish = onAudioRecordingFinish, + onAudioRecordingLock = onAudioRecordingLock, onAudioRecordingCancel = onAudioRecordingCancel, onSendClick = onSendClick, ) diff --git a/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationSendActionButton.kt b/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationSendActionButton.kt index 766fc768..f8f9c103 100644 --- a/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationSendActionButton.kt +++ b/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationSendActionButton.kt @@ -35,6 +35,7 @@ import androidx.compose.runtime.Immutable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha @@ -47,6 +48,8 @@ import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.onClick +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp import com.android.messaging.R @@ -54,8 +57,15 @@ import com.android.messaging.R internal enum class ConversationSendActionButtonMode { Send, Record, + Stop, } +@Immutable +internal data class ConversationSendActionButtonGestureState( + val cancelDragDistancePx: Float = 0f, + val lockDragDistancePx: Float = 0f, +) + @Immutable private data class ConversationSendActionButtonVisualState( val buttonScale: Float, @@ -69,9 +79,12 @@ internal fun ConversationSendActionButton( enabled: Boolean, mode: ConversationSendActionButtonMode, isRecordingActive: Boolean, + isRecordingLocked: Boolean, onClick: () -> Unit, + onLockedStopClick: () -> Unit, onRecordGestureStart: () -> Unit, - onRecordGestureMove: (Float) -> Unit, + onRecordGestureMove: (ConversationSendActionButtonGestureState) -> Unit, + onRecordGestureLock: () -> Boolean, onRecordGestureFinish: (Boolean) -> Unit, ) { var isRecordGestureActive by remember(mode, enabled) { @@ -84,19 +97,27 @@ internal fun ConversationSendActionButton( ) val cancelThresholdPx = with(LocalDensity.current) { - 96.dp.toPx() + AUDIO_RECORD_CANCEL_THRESHOLD.toPx() + } + val lockThresholdPx = with(LocalDensity.current) { + AUDIO_RECORD_LOCK_THRESHOLD.toPx() } val gestureModifier = Modifier.conversationSendActionButtonGesture( mode = mode, enabled = enabled, cancelThresholdPx = cancelThresholdPx, + lockThresholdPx = lockThresholdPx, + isRecordingActive = isRecordingActive, + isRecordingLocked = isRecordingLocked, onGestureActiveChange = { isActive -> isRecordGestureActive = isActive }, onRecordGestureStart = onRecordGestureStart, onRecordGestureMove = onRecordGestureMove, + onRecordGestureLock = onRecordGestureLock, onRecordGestureFinish = onRecordGestureFinish, + onLockedStopClick = onLockedStopClick, ) ConversationSendActionButtonLayout( @@ -106,6 +127,7 @@ internal fun ConversationSendActionButton( enabled = enabled, mode = mode, onClick = onClick, + onLockedStopClick = onLockedStopClick, visualState = visualState, ) } @@ -172,31 +194,64 @@ private fun animateConversationSendActionButtonVisualState( ) } +@Composable private fun Modifier.conversationSendActionButtonGesture( mode: ConversationSendActionButtonMode, enabled: Boolean, cancelThresholdPx: Float, + lockThresholdPx: Float, + isRecordingActive: Boolean, + isRecordingLocked: Boolean, onGestureActiveChange: (Boolean) -> Unit, onRecordGestureStart: () -> Unit, - onRecordGestureMove: (Float) -> Unit, + onRecordGestureMove: (ConversationSendActionButtonGestureState) -> Unit, + onRecordGestureLock: () -> Boolean, onRecordGestureFinish: (Boolean) -> Unit, + onLockedStopClick: () -> Unit, ): Modifier { + val currentIsRecordingActive by rememberUpdatedState(newValue = isRecordingActive) + val currentIsRecordingLocked by rememberUpdatedState(newValue = isRecordingLocked) + val currentOnGestureActiveChange by rememberUpdatedState(newValue = onGestureActiveChange) + val currentOnRecordGestureStart by rememberUpdatedState(newValue = onRecordGestureStart) + val currentOnRecordGestureMove by rememberUpdatedState(newValue = onRecordGestureMove) + val currentOnRecordGestureLock by rememberUpdatedState(newValue = onRecordGestureLock) + val currentOnRecordGestureFinish by rememberUpdatedState(newValue = onRecordGestureFinish) + val currentOnLockedStopClick by rememberUpdatedState(newValue = onLockedStopClick) + return when { - mode == ConversationSendActionButtonMode.Record && enabled -> { - then( - Modifier - .pointerInput(mode, true, cancelThresholdPx) { - awaitEachGesture { + mode != ConversationSendActionButtonMode.Send && enabled -> { + pointerInput( + mode, + enabled, + cancelThresholdPx, + lockThresholdPx, + ) { + awaitEachGesture { + when { + currentIsRecordingActive && currentIsRecordingLocked -> { + handleLockedRecordGesture( + cancelThresholdPx = cancelThresholdPx, + onGestureActiveChange = currentOnGestureActiveChange, + onRecordGestureMove = currentOnRecordGestureMove, + onRecordGestureFinish = currentOnRecordGestureFinish, + onLockedStopClick = currentOnLockedStopClick, + ) + } + + else -> { handleRecordGesture( cancelThresholdPx = cancelThresholdPx, - onGestureActiveChange = onGestureActiveChange, - onRecordGestureStart = onRecordGestureStart, - onRecordGestureMove = onRecordGestureMove, - onRecordGestureFinish = onRecordGestureFinish, + lockThresholdPx = lockThresholdPx, + onGestureActiveChange = currentOnGestureActiveChange, + onRecordGestureStart = currentOnRecordGestureStart, + onRecordGestureMove = currentOnRecordGestureMove, + onRecordGestureLock = currentOnRecordGestureLock, + onRecordGestureFinish = currentOnRecordGestureFinish, ) } - }, - ) + } + } + } } else -> this @@ -205,58 +260,146 @@ private fun Modifier.conversationSendActionButtonGesture( private suspend fun AwaitPointerEventScope.handleRecordGesture( cancelThresholdPx: Float, + lockThresholdPx: Float, onGestureActiveChange: (Boolean) -> Unit, onRecordGestureStart: () -> Unit, - onRecordGestureMove: (Float) -> Unit, + onRecordGestureMove: (ConversationSendActionButtonGestureState) -> Unit, + onRecordGestureLock: () -> Boolean, onRecordGestureFinish: (Boolean) -> Unit, ) { - val down = awaitFirstDown(requireUnconsumed = false) + val initialDown = awaitFirstDown(requireUnconsumed = false) - val longPressChange = awaitLongPressOrCancellation(pointerId = down.id) + val longPressChange = awaitLongPressOrCancellation(pointerId = initialDown.id) ?: return onGestureActiveChange(true) onRecordGestureStart() trackRecordGestureDrag( - down = down, + initialDown = initialDown, longPressChange = longPressChange, cancelThresholdPx = cancelThresholdPx, + lockThresholdPx = lockThresholdPx, onGestureActiveChange = onGestureActiveChange, onRecordGestureMove = onRecordGestureMove, + onRecordGestureLock = onRecordGestureLock, onRecordGestureFinish = onRecordGestureFinish, ) } private suspend fun AwaitPointerEventScope.trackRecordGestureDrag( - down: PointerInputChange, + initialDown: PointerInputChange, longPressChange: PointerInputChange, cancelThresholdPx: Float, + lockThresholdPx: Float, onGestureActiveChange: (Boolean) -> Unit, - onRecordGestureMove: (Float) -> Unit, + onRecordGestureMove: (ConversationSendActionButtonGestureState) -> Unit, + onRecordGestureLock: () -> Boolean, onRecordGestureFinish: (Boolean) -> Unit, ) { - var shouldCancel: Boolean + var isRecordingLocked = false longPressChange.consume() while (true) { - val pointerChange = awaitRecordGestureChange(pointerId = down.id) ?: break - val dragDistance = calculateRecordGestureDragDistance( - down = down, + val pointerChange = awaitRecordGestureChange(pointerId = initialDown.id) ?: break + val gestureState = calculateRecordGestureState( + initialDown = initialDown, pointerChange = pointerChange, ) - onRecordGestureMove(dragDistance) - shouldCancel = dragDistance >= cancelThresholdPx + if (!isRecordingLocked) { + onRecordGestureMove(gestureState) + + if (gestureState.lockDragDistancePx >= lockThresholdPx) { + isRecordingLocked = onRecordGestureLock() + + if (isRecordingLocked) { + onRecordGestureMove(ConversationSendActionButtonGestureState()) + } + } + } + + pointerChange.consume() + + if (pointerChange.pressed) { + continue + } + + resetRecordGestureDragUi( + onGestureActiveChange = onGestureActiveChange, + onRecordGestureMove = onRecordGestureMove, + ) + + if (!isRecordingLocked) { + onRecordGestureFinish(gestureState.cancelDragDistancePx >= cancelThresholdPx) + } + + return + } + + resetRecordGestureDragUi( + onGestureActiveChange = onGestureActiveChange, + onRecordGestureMove = onRecordGestureMove, + ) +} + +private suspend fun AwaitPointerEventScope.handleLockedRecordGesture( + cancelThresholdPx: Float, + onGestureActiveChange: (Boolean) -> Unit, + onRecordGestureMove: (ConversationSendActionButtonGestureState) -> Unit, + onRecordGestureFinish: (Boolean) -> Unit, + onLockedStopClick: () -> Unit, +) { + val initialDown = awaitFirstDown(requireUnconsumed = false) + + onGestureActiveChange(true) + initialDown.consume() + + while (true) { + val pointerChange = awaitRecordGestureChange(pointerId = initialDown.id) ?: break + val gestureState = calculateRecordGestureState( + initialDown = initialDown, + pointerChange = pointerChange, + ) + + onRecordGestureMove( + ConversationSendActionButtonGestureState( + cancelDragDistancePx = gestureState.cancelDragDistancePx, + ), + ) pointerChange.consume() if (!pointerChange.pressed) { - onGestureActiveChange(false) - onRecordGestureFinish(shouldCancel) + resetRecordGestureDragUi( + onGestureActiveChange = onGestureActiveChange, + onRecordGestureMove = onRecordGestureMove, + ) + when { + gestureState.cancelDragDistancePx >= cancelThresholdPx -> { + onRecordGestureFinish(true) + } + + else -> { + onLockedStopClick() + } + } return } } + + resetRecordGestureDragUi( + onGestureActiveChange = onGestureActiveChange, + onRecordGestureMove = onRecordGestureMove, + ) +} + +private fun resetRecordGestureDragUi( + onGestureActiveChange: (Boolean) -> Unit, + onRecordGestureMove: (ConversationSendActionButtonGestureState) -> Unit, +) { + onGestureActiveChange(false) + onRecordGestureMove(ConversationSendActionButtonGestureState()) } private suspend fun AwaitPointerEventScope.awaitRecordGestureChange( @@ -269,12 +412,16 @@ private suspend fun AwaitPointerEventScope.awaitRecordGestureChange( } } -private fun calculateRecordGestureDragDistance( - down: PointerInputChange, +private fun calculateRecordGestureState( + initialDown: PointerInputChange, pointerChange: PointerInputChange, -): Float { - return (down.position.x - pointerChange.position.x) - .coerceAtLeast(minimumValue = 0f) +): ConversationSendActionButtonGestureState { + return ConversationSendActionButtonGestureState( + cancelDragDistancePx = (initialDown.position.x - pointerChange.position.x) + .coerceAtLeast(minimumValue = 0f), + lockDragDistancePx = (initialDown.position.y - pointerChange.position.y) + .coerceAtLeast(minimumValue = 0f), + ) } @Composable @@ -285,6 +432,7 @@ private fun ConversationSendActionButtonLayout( enabled: Boolean, mode: ConversationSendActionButtonMode, onClick: () -> Unit, + onLockedStopClick: () -> Unit, visualState: ConversationSendActionButtonVisualState, ) { Box( @@ -299,6 +447,7 @@ private fun ConversationSendActionButtonLayout( enabled = enabled, mode = mode, onClick = onClick, + onLockedStopClick = onLockedStopClick, visualState = visualState, ) } @@ -310,12 +459,30 @@ private fun ConversationSendActionButtonContent( enabled: Boolean, mode: ConversationSendActionButtonMode, onClick: () -> Unit, + onLockedStopClick: () -> Unit, visualState: ConversationSendActionButtonVisualState, ) { + val stopContentDescription = stringResource( + id = R.string.audio_record_stop_content_description, + ) + val stopSemanticsModifier = when (mode) { + ConversationSendActionButtonMode.Stop -> { + Modifier.semantics { + onClick(label = stopContentDescription) { + onLockedStopClick() + true + } + } + } + + else -> Modifier + } + FilledIconButton( modifier = Modifier .fillMaxSize() .scale(scale = visualState.buttonScale) + .then(stopSemanticsModifier) .then(modifier), onClick = { if (mode == ConversationSendActionButtonMode.Send) { @@ -376,6 +543,15 @@ private fun ConversationSendActionButtonIcon(mode: ConversationSendActionButtonM ), ) } + + ConversationSendActionButtonMode.Stop -> { + Icon( + painter = painterResource(id = R.drawable.ic_mp_capture_stop_large_light), + contentDescription = stringResource( + id = R.string.audio_record_stop_content_description, + ), + ) + } } } } diff --git a/src/com/android/messaging/ui/conversation/v2/mediapicker/component/review/ConversationMediaPickerReview.kt b/src/com/android/messaging/ui/conversation/v2/mediapicker/component/review/ConversationMediaPickerReview.kt index 48277926..0114e829 100644 --- a/src/com/android/messaging/ui/conversation/v2/mediapicker/component/review/ConversationMediaPickerReview.kt +++ b/src/com/android/messaging/ui/conversation/v2/mediapicker/component/review/ConversationMediaPickerReview.kt @@ -352,9 +352,12 @@ private fun ConversationMediaReviewBottomBar( enabled = isSendActionEnabled, mode = ConversationSendActionButtonMode.Send, isRecordingActive = false, + isRecordingLocked = false, onClick = onSendClick, + onLockedStopClick = {}, onRecordGestureStart = {}, - onRecordGestureMove = {}, + onRecordGestureMove = { _ -> }, + onRecordGestureLock = { false }, onRecordGestureFinish = {}, ) } diff --git a/src/com/android/messaging/ui/conversation/v2/screen/ConversationScreen.kt b/src/com/android/messaging/ui/conversation/v2/screen/ConversationScreen.kt index 997e4e21..76fefbe4 100644 --- a/src/com/android/messaging/ui/conversation/v2/screen/ConversationScreen.kt +++ b/src/com/android/messaging/ui/conversation/v2/screen/ConversationScreen.kt @@ -25,7 +25,6 @@ import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.geometry.Rect as ComposeRect import androidx.compose.ui.layout.boundsInWindow import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalContext @@ -39,6 +38,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.android.messaging.R import com.android.messaging.data.conversation.model.draft.ConversationDraft import com.android.messaging.ui.conversation.v2.CONVERSATION_LOADING_INDICATOR_TEST_TAG +import com.android.messaging.ui.conversation.v2.audio.model.ConversationAudioRecordingPhase import com.android.messaging.ui.conversation.v2.composer.model.ComposerAttachmentUiModel import com.android.messaging.ui.conversation.v2.composer.ui.ConversationComposerSection import com.android.messaging.ui.conversation.v2.composer.ui.ConversationSimSelectorSheet @@ -55,6 +55,7 @@ import com.android.messaging.ui.conversation.v2.screen.model.ConversationMessage import com.android.messaging.ui.conversation.v2.screen.model.ConversationMessageSelectionAction import com.android.messaging.ui.conversation.v2.screen.model.ConversationScreenScaffoldUiState import kotlinx.collections.immutable.ImmutableList +import androidx.compose.ui.geometry.Rect as ComposeRect @Composable internal fun ConversationScreen( @@ -156,7 +157,10 @@ internal fun ConversationScreen( ) LifecycleEventEffect(event = Lifecycle.Event.ON_STOP) { - if (scaffoldUiState.composer.audioRecording.isRecording) { + val isRecording = scaffoldUiState.composer.audioRecording.phase == + ConversationAudioRecordingPhase.Recording + + if (isRecording) { screenModel.onAudioRecordingCancel() } screenModel.persistDraft() @@ -219,6 +223,7 @@ internal fun ConversationScreen( } }, onAudioRecordingFinish = screenModel::onAudioRecordingFinish, + onAudioRecordingLock = screenModel::onAudioRecordingLock, onAudioRecordingCancel = screenModel::onAudioRecordingCancel, onSendClick = screenModel::onSendClick, onSimSelected = screenModel::onSimSelected, @@ -279,6 +284,7 @@ private fun ConversationScreenScaffold( onResolvedAttachmentRemove: (String) -> Unit, onAudioRecordingStartRequest: () -> Unit, onAudioRecordingFinish: () -> Unit, + onAudioRecordingLock: () -> Boolean, onAudioRecordingCancel: () -> Unit, onSendClick: () -> Unit, onSimSelected: (String) -> Unit, @@ -349,6 +355,7 @@ private fun ConversationScreenScaffold( onResolvedAttachmentRemove = onResolvedAttachmentRemove, onAudioRecordingStartRequest = onAudioRecordingStartRequest, onAudioRecordingFinish = onAudioRecordingFinish, + onAudioRecordingLock = onAudioRecordingLock, onAudioRecordingCancel = onAudioRecordingCancel, onSendClick = onSendClick, ) diff --git a/src/com/android/messaging/ui/conversation/v2/screen/ConversationViewModel.kt b/src/com/android/messaging/ui/conversation/v2/screen/ConversationViewModel.kt index b923f835..9c5301a2 100644 --- a/src/com/android/messaging/ui/conversation/v2/screen/ConversationViewModel.kt +++ b/src/com/android/messaging/ui/conversation/v2/screen/ConversationViewModel.kt @@ -81,6 +81,7 @@ internal interface ConversationScreenModel { fun onContactCardPicked(contactUri: String?) fun onMessageTextChanged(text: String) fun onAudioRecordingStart() + fun onAudioRecordingLock(): Boolean fun onAudioRecordingFinish() fun onAudioRecordingCancel() fun onGalleryVisibilityChanged(isVisible: Boolean) @@ -488,6 +489,10 @@ internal class ConversationViewModel @Inject constructor( ) } + override fun onAudioRecordingLock(): Boolean { + return conversationAudioRecordingDelegate.lockRecording() + } + override fun onAudioRecordingFinish() { conversationAudioRecordingDelegate.finishRecording() } From eae89cc566d696a806eabac81179d94643d37704 Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Fri, 24 Apr 2026 04:36:16 +0300 Subject: [PATCH 58/99] Add audio recording attachment to the attachments menu --- .../conversation/v2/ConversationTestTags.kt | 2 + .../ConversationAudioRecordingDelegate.kt | 34 +++++-- .../v2/composer/ui/ConversationComposeBar.kt | 91 ++++++++++++++----- .../ui/ConversationComposerSection.kt | 2 + .../v2/screen/ConversationScreen.kt | 59 +++++++++--- .../v2/screen/ConversationViewModel.kt | 25 ++++- 6 files changed, 167 insertions(+), 46 deletions(-) diff --git a/src/com/android/messaging/ui/conversation/v2/ConversationTestTags.kt b/src/com/android/messaging/ui/conversation/v2/ConversationTestTags.kt index d9624ac5..cf1091e3 100644 --- a/src/com/android/messaging/ui/conversation/v2/ConversationTestTags.kt +++ b/src/com/android/messaging/ui/conversation/v2/ConversationTestTags.kt @@ -7,6 +7,8 @@ internal const val CONVERSATION_COMPOSE_BAR_TEST_TAG = "conversation_compose_bar internal const val CONVERSATION_ATTACHMENT_BUTTON_TEST_TAG = "conversation_attachment_button" internal const val CONVERSATION_ATTACHMENT_CONTACT_MENU_ITEM_TEST_TAG = "conversation_attachment_contact_menu_item" +internal const val CONVERSATION_ATTACHMENT_AUDIO_MENU_ITEM_TEST_TAG = + "conversation_attachment_audio_menu_item" internal const val CONVERSATION_ATTACHMENT_MEDIA_MENU_ITEM_TEST_TAG = "conversation_attachment_media_menu_item" internal const val CONVERSATION_ATTACHMENT_PREVIEW_LIST_TEST_TAG = diff --git a/src/com/android/messaging/ui/conversation/v2/audio/delegate/ConversationAudioRecordingDelegate.kt b/src/com/android/messaging/ui/conversation/v2/audio/delegate/ConversationAudioRecordingDelegate.kt index 8d2f202e..32e9bc27 100644 --- a/src/com/android/messaging/ui/conversation/v2/audio/delegate/ConversationAudioRecordingDelegate.kt +++ b/src/com/android/messaging/ui/conversation/v2/audio/delegate/ConversationAudioRecordingDelegate.kt @@ -15,6 +15,9 @@ import com.android.messaging.ui.conversation.v2.mediapicker.repository.Conversat import com.android.messaging.ui.mediapicker.LevelTrackingMediaRecorder import com.android.messaging.util.ContentType import com.android.messaging.util.LogUtil +import java.util.UUID +import javax.inject.Inject +import kotlin.time.Duration.Companion.milliseconds import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineStart @@ -29,15 +32,14 @@ import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.first import kotlinx.coroutines.isActive import kotlinx.coroutines.launch -import java.util.UUID -import javax.inject.Inject -import kotlin.time.Duration.Companion.milliseconds internal interface ConversationAudioRecordingDelegate : ConversationScreenDelegate { fun startRecording(selfParticipantId: String) + fun startLockedRecording(selfParticipantId: String) + fun lockRecording(): Boolean fun finishRecording() @@ -80,6 +82,23 @@ internal class ConversationAudioRecordingDelegateImpl @Inject constructor( } override fun startRecording(selfParticipantId: String) { + startRecording( + selfParticipantId = selfParticipantId, + queuedStartIntent = QueuedStartIntent.None, + ) + } + + override fun startLockedRecording(selfParticipantId: String) { + startRecording( + selfParticipantId = selfParticipantId, + queuedStartIntent = QueuedStartIntent.Lock, + ) + } + + private fun startRecording( + selfParticipantId: String, + queuedStartIntent: QueuedStartIntent, + ) { val scope = boundScope ?: return val startJob = scope.launch( context = defaultDispatcher, @@ -89,13 +108,12 @@ internal class ConversationAudioRecordingDelegateImpl @Inject constructor( } val shouldStartJob = withSessionStateLock { - tryStartRecordingLocked() + tryStartRecordingLocked(queuedStartIntent = queuedStartIntent) } when { shouldStartJob -> startJob.start() else -> startJob.cancel() - } } @@ -155,13 +173,15 @@ internal class ConversationAudioRecordingDelegateImpl @Inject constructor( } } - private fun tryStartRecordingLocked(): Boolean { + private fun tryStartRecordingLocked(queuedStartIntent: QueuedStartIntent): Boolean { if (sessionState !is AudioRecordingSessionState.Idle) { return false } - sessionState = AudioRecordingSessionState.Starting() + sessionState = AudioRecordingSessionState.Starting(queuedStartIntent) + publishUiStateLocked() + return true } diff --git a/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationComposeBar.kt b/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationComposeBar.kt index 0233cd1e..54431e59 100644 --- a/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationComposeBar.kt +++ b/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationComposeBar.kt @@ -1,5 +1,6 @@ package com.android.messaging.ui.conversation.v2.composer.ui +import androidx.annotation.StringRes import androidx.compose.animation.AnimatedContent import androidx.compose.animation.ContentTransform import androidx.compose.animation.core.tween @@ -18,10 +19,12 @@ import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.AddCircleOutline import androidx.compose.material.icons.rounded.Image +import androidx.compose.material.icons.rounded.Mic import androidx.compose.material.icons.rounded.Person import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem @@ -44,6 +47,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color +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 @@ -53,6 +57,7 @@ import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.dp import com.android.messaging.R +import com.android.messaging.ui.conversation.v2.CONVERSATION_ATTACHMENT_AUDIO_MENU_ITEM_TEST_TAG import com.android.messaging.ui.conversation.v2.CONVERSATION_ATTACHMENT_BUTTON_TEST_TAG import com.android.messaging.ui.conversation.v2.CONVERSATION_ATTACHMENT_CONTACT_MENU_ITEM_TEST_TAG import com.android.messaging.ui.conversation.v2.CONVERSATION_ATTACHMENT_MEDIA_MENU_ITEM_TEST_TAG @@ -81,6 +86,7 @@ internal fun ConversationComposeBar( messageFieldFocusRequester: FocusRequester? = null, onContactAttachClick: () -> Unit, onMediaPickerClick: () -> Unit, + onLockedAudioRecordingStartRequest: () -> Unit, onMessageTextChange: (String) -> Unit, onAudioRecordingStartRequest: () -> Unit, onAudioRecordingFinish: () -> Unit, @@ -121,6 +127,7 @@ internal fun ConversationComposeBar( presentation = presentation, onContactAttachClick = onContactAttachClick, onMediaPickerClick = onMediaPickerClick, + onLockedAudioRecordingStartRequest = onLockedAudioRecordingStartRequest, onMessageTextChange = onMessageTextChange, onAudioRecordingStartRequest = { recordingGestureState = ConversationSendActionButtonGestureState() @@ -203,6 +210,7 @@ private fun ConversationComposeInputContent( presentation: ConversationComposeBarPresentation, onContactAttachClick: () -> Unit, onMediaPickerClick: () -> Unit, + onLockedAudioRecordingStartRequest: () -> Unit, onMessageTextChange: (String) -> Unit, onAudioRecordingStartRequest: () -> Unit, onAudioRecordingDrag: (ConversationSendActionButtonGestureState) -> Unit, @@ -275,8 +283,10 @@ private fun ConversationComposeInputContent( messageFieldFocusRequester = messageFieldFocusRequester, presentation = presentation, isAttachmentActionEnabled = isAttachmentActionEnabled, + isAudioRecordActionEnabled = isRecordActionEnabled, onContactAttachClick = onContactAttachClick, onMediaPickerClick = onMediaPickerClick, + onAudioAttachClick = onLockedAudioRecordingStartRequest, ) } } @@ -318,9 +328,11 @@ private fun ConversationComposeMessageField( messageFieldFocusRequester: FocusRequester?, presentation: ConversationComposeBarPresentation, isAttachmentActionEnabled: Boolean, + isAudioRecordActionEnabled: Boolean, onValueChange: (String) -> Unit, onContactAttachClick: () -> Unit, onMediaPickerClick: () -> Unit, + onAudioAttachClick: () -> Unit, ) { val focusRequesterModifier = messageFieldFocusRequester ?.let(Modifier::focusRequester) @@ -341,8 +353,10 @@ private fun ConversationComposeMessageField( ConversationComposeAttachmentMenu( modifier = Modifier.testTag(CONVERSATION_ATTACHMENT_BUTTON_TEST_TAG), enabled = isAttachmentActionEnabled, + isAudioRecordActionEnabled = isAudioRecordActionEnabled, onContactAttachClick = onContactAttachClick, onMediaPickerClick = onMediaPickerClick, + onAudioAttachClick = onAudioAttachClick, ) }, minLines = 1, @@ -382,14 +396,21 @@ private fun contentSwapTransition(): ContentTransform { private fun ConversationComposeAttachmentMenu( modifier: Modifier = Modifier, enabled: Boolean, + isAudioRecordActionEnabled: Boolean, onContactAttachClick: () -> Unit, onMediaPickerClick: () -> Unit, + onAudioAttachClick: () -> Unit, ) { val hapticFeedback = LocalHapticFeedback.current var isExpanded by rememberSaveable { mutableStateOf(value = false) } + fun closeMenuAndRun(action: () -> Unit) { + isExpanded = false + action() + } + Box( modifier = modifier, ) { @@ -414,47 +435,71 @@ private fun ConversationComposeAttachmentMenu( onDismissRequest = { isExpanded = false }, + shape = RoundedCornerShape(size = 24.dp), + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + tonalElevation = 3.dp, + shadowElevation = 6.dp, offset = DpOffset( x = 0.dp, y = (-8).dp, ), ) { - DropdownMenuItem( + ConversationComposeAttachmentMenuItem( modifier = Modifier.testTag(CONVERSATION_ATTACHMENT_MEDIA_MENU_ITEM_TEST_TAG), - text = { - Text(text = stringResource(id = R.string.mediapicker_gallery_title)) - }, - leadingIcon = { - Icon( - imageVector = Icons.Rounded.Image, - contentDescription = null, - ) + imageVector = Icons.Rounded.Image, + textResId = R.string.mediapicker_gallery_title, + onClick = { + closeMenuAndRun(action = onMediaPickerClick) }, + ) + ConversationComposeAttachmentMenuItem( + modifier = Modifier.testTag(CONVERSATION_ATTACHMENT_AUDIO_MENU_ITEM_TEST_TAG), + imageVector = Icons.Rounded.Mic, + textResId = R.string.mediapicker_audio_title, + enabled = isAudioRecordActionEnabled, onClick = { - isExpanded = false - onMediaPickerClick() + closeMenuAndRun(action = onAudioAttachClick) }, ) - DropdownMenuItem( + + ConversationComposeAttachmentMenuItem( modifier = Modifier.testTag(CONVERSATION_ATTACHMENT_CONTACT_MENU_ITEM_TEST_TAG), - text = { - Text(text = stringResource(id = R.string.mediapicker_contact_title)) - }, - leadingIcon = { - Icon( - imageVector = Icons.Rounded.Person, - contentDescription = null, - ) - }, + imageVector = Icons.Rounded.Person, + textResId = R.string.mediapicker_contact_title, onClick = { - isExpanded = false - onContactAttachClick() + closeMenuAndRun(action = onContactAttachClick) }, ) } } } +@Composable +private fun ConversationComposeAttachmentMenuItem( + modifier: Modifier = Modifier, + imageVector: ImageVector, + @StringRes textResId: Int, + enabled: Boolean = true, + onClick: () -> Unit, +) { + DropdownMenuItem( + modifier = modifier, + text = { + Text(text = stringResource(id = textResId)) + }, + leadingIcon = { + Icon( + imageVector = imageVector, + contentDescription = null, + modifier = Modifier.size(size = 24.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + enabled = enabled, + onClick = onClick, + ) +} + @Composable private fun ConversationComposeSendAction( modifier: Modifier = Modifier, diff --git a/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationComposerSection.kt b/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationComposerSection.kt index eea9972f..9ce4cc72 100644 --- a/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationComposerSection.kt +++ b/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationComposerSection.kt @@ -27,6 +27,7 @@ internal fun ConversationComposerSection( onResolvedAttachmentClick: (ComposerAttachmentUiModel.Resolved) -> Unit, onResolvedAttachmentRemove: (String) -> Unit, onAudioRecordingStartRequest: () -> Unit, + onLockedAudioRecordingStartRequest: () -> Unit, onAudioRecordingFinish: () -> Unit, onAudioRecordingLock: () -> Boolean, onAudioRecordingCancel: () -> Unit, @@ -53,6 +54,7 @@ internal fun ConversationComposerSection( messageFieldFocusRequester = messageFieldFocusRequester, onContactAttachClick = onContactAttachClick, onMediaPickerClick = onMediaPickerClick, + onLockedAudioRecordingStartRequest = onLockedAudioRecordingStartRequest, onMessageTextChange = onMessageTextChange, onAudioRecordingStartRequest = onAudioRecordingStartRequest, onAudioRecordingFinish = onAudioRecordingFinish, diff --git a/src/com/android/messaging/ui/conversation/v2/screen/ConversationScreen.kt b/src/com/android/messaging/ui/conversation/v2/screen/ConversationScreen.kt index 76fefbe4..60c9ae2f 100644 --- a/src/com/android/messaging/ui/conversation/v2/screen/ConversationScreen.kt +++ b/src/com/android/messaging/ui/conversation/v2/screen/ConversationScreen.kt @@ -25,6 +25,7 @@ import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.geometry.Rect as ComposeRect import androidx.compose.ui.layout.boundsInWindow import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalContext @@ -55,7 +56,12 @@ import com.android.messaging.ui.conversation.v2.screen.model.ConversationMessage import com.android.messaging.ui.conversation.v2.screen.model.ConversationMessageSelectionAction import com.android.messaging.ui.conversation.v2.screen.model.ConversationScreenScaffoldUiState import kotlinx.collections.immutable.ImmutableList -import androidx.compose.ui.geometry.Rect as ComposeRect + +private enum class PendingAudioRecordingStartMode { + None, + Unlocked, + Locked, +} @Composable internal fun ConversationScreen( @@ -87,8 +93,8 @@ internal fun ConversationScreen( mutableStateOf(value = null) } - var shouldStartAudioRecordingAfterPermissionGrant by rememberSaveable { - mutableStateOf(value = false) + var pendingAudioRecordingStartMode by rememberSaveable { + mutableStateOf(value = PendingAudioRecordingStartMode.None) } val contactPickerLauncher = rememberLauncherForActivityResult( @@ -102,13 +108,29 @@ internal fun ConversationScreen( ) { isGranted -> permissionState.audioPermissionGranted = isGranted - if (!isGranted || !shouldStartAudioRecordingAfterPermissionGrant) { - shouldStartAudioRecordingAfterPermissionGrant = false + val startMode = pendingAudioRecordingStartMode + pendingAudioRecordingStartMode = PendingAudioRecordingStartMode.None + + if (!isGranted) { return@rememberLauncherForActivityResult } - shouldStartAudioRecordingAfterPermissionGrant = false - screenModel.onAudioRecordingStart() + startAudioRecording( + screenModel = screenModel, + startMode = startMode, + ) + } + + val requestAudioRecordingStart = { startMode: PendingAudioRecordingStartMode -> + if (permissionState.audioPermissionGranted) { + startAudioRecording( + screenModel = screenModel, + startMode = startMode, + ) + } else { + pendingAudioRecordingStartMode = startMode + audioPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO) + } } LaunchedEffect(conversationId) { @@ -215,12 +237,10 @@ internal fun ConversationScreen( onResolvedAttachmentClick = screenModel::onAttachmentClicked, onResolvedAttachmentRemove = screenModel::onRemoveResolvedAttachment, onAudioRecordingStartRequest = { - if (permissionState.audioPermissionGranted) { - screenModel.onAudioRecordingStart() - } else { - shouldStartAudioRecordingAfterPermissionGrant = true - audioPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO) - } + requestAudioRecordingStart(PendingAudioRecordingStartMode.Unlocked) + }, + onLockedAudioRecordingStartRequest = { + requestAudioRecordingStart(PendingAudioRecordingStartMode.Locked) }, onAudioRecordingFinish = screenModel::onAudioRecordingFinish, onAudioRecordingLock = screenModel::onAudioRecordingLock, @@ -253,6 +273,17 @@ internal fun ConversationScreen( } } +private fun startAudioRecording( + screenModel: ConversationScreenModel, + startMode: PendingAudioRecordingStartMode, +) { + when (startMode) { + PendingAudioRecordingStartMode.None -> Unit + PendingAudioRecordingStartMode.Unlocked -> screenModel.onAudioRecordingStart() + PendingAudioRecordingStartMode.Locked -> screenModel.onLockedAudioRecordingStart() + } +} + @Composable private fun ConversationScreenScaffold( modifier: Modifier = Modifier, @@ -283,6 +314,7 @@ private fun ConversationScreenScaffold( onResolvedAttachmentClick: (ComposerAttachmentUiModel.Resolved) -> Unit, onResolvedAttachmentRemove: (String) -> Unit, onAudioRecordingStartRequest: () -> Unit, + onLockedAudioRecordingStartRequest: () -> Unit, onAudioRecordingFinish: () -> Unit, onAudioRecordingLock: () -> Boolean, onAudioRecordingCancel: () -> Unit, @@ -354,6 +386,7 @@ private fun ConversationScreenScaffold( onResolvedAttachmentClick = onResolvedAttachmentClick, onResolvedAttachmentRemove = onResolvedAttachmentRemove, onAudioRecordingStartRequest = onAudioRecordingStartRequest, + onLockedAudioRecordingStartRequest = onLockedAudioRecordingStartRequest, onAudioRecordingFinish = onAudioRecordingFinish, onAudioRecordingLock = onAudioRecordingLock, onAudioRecordingCancel = onAudioRecordingCancel, diff --git a/src/com/android/messaging/ui/conversation/v2/screen/ConversationViewModel.kt b/src/com/android/messaging/ui/conversation/v2/screen/ConversationViewModel.kt index 9c5301a2..02dcf039 100644 --- a/src/com/android/messaging/ui/conversation/v2/screen/ConversationViewModel.kt +++ b/src/com/android/messaging/ui/conversation/v2/screen/ConversationViewModel.kt @@ -81,6 +81,7 @@ internal interface ConversationScreenModel { fun onContactCardPicked(contactUri: String?) fun onMessageTextChanged(text: String) fun onAudioRecordingStart() + fun onLockedAudioRecordingStart() fun onAudioRecordingLock(): Boolean fun onAudioRecordingFinish() fun onAudioRecordingCancel() @@ -478,15 +479,33 @@ internal class ConversationViewModel @Inject constructor( } override fun onAudioRecordingStart() { + startAudioRecording(isLocked = false) + } + + override fun onLockedAudioRecordingStart() { + startAudioRecording(isLocked = true) + } + + private fun startAudioRecording(isLocked: Boolean) { val effectiveSelfParticipantId = composerUiState.value .simSelector .selectedSubscription ?.selfParticipantId ?: conversationDraftDelegate.state.value.draft.selfParticipantId - conversationAudioRecordingDelegate.startRecording( - selfParticipantId = effectiveSelfParticipantId, - ) + when { + isLocked -> { + conversationAudioRecordingDelegate.startLockedRecording( + selfParticipantId = effectiveSelfParticipantId, + ) + } + + else -> { + conversationAudioRecordingDelegate.startRecording( + selfParticipantId = effectiveSelfParticipantId, + ) + } + } } override fun onAudioRecordingLock(): Boolean { From e83658eae1c9e6e96b828314af09dec3214730bb Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Sun, 26 Apr 2026 21:27:03 +0300 Subject: [PATCH 59/99] Handle new messages and notifications in Compose conversation --- .../ConversationViewModelBindsModule.kt | 8 ++ .../conversation/v2/ConversationActivity.kt | 1 + .../model/ConversationEntryLaunchRequest.kt | 1 + .../delegate/ConversationFocusDelegate.kt | 107 ++++++++++++++++++ .../v2/navigation/ConversationNavGraph.kt | 4 + .../v2/screen/ConversationScreen.kt | 9 ++ .../v2/screen/ConversationViewModel.kt | 21 ++++ 7 files changed, 151 insertions(+) create mode 100644 src/com/android/messaging/ui/conversation/v2/focus/delegate/ConversationFocusDelegate.kt diff --git a/src/com/android/messaging/di/conversation/ConversationViewModelBindsModule.kt b/src/com/android/messaging/di/conversation/ConversationViewModelBindsModule.kt index 564e9fbc..c23696af 100644 --- a/src/com/android/messaging/di/conversation/ConversationViewModelBindsModule.kt +++ b/src/com/android/messaging/di/conversation/ConversationViewModelBindsModule.kt @@ -6,6 +6,8 @@ import com.android.messaging.ui.conversation.v2.composer.delegate.ConversationCo import com.android.messaging.ui.conversation.v2.composer.delegate.ConversationComposerAttachmentsDelegateImpl import com.android.messaging.ui.conversation.v2.composer.delegate.ConversationDraftDelegate import com.android.messaging.ui.conversation.v2.composer.delegate.ConversationDraftDelegateImpl +import com.android.messaging.ui.conversation.v2.focus.delegate.ConversationFocusDelegate +import com.android.messaging.ui.conversation.v2.focus.delegate.ConversationFocusDelegateImpl import com.android.messaging.ui.conversation.v2.mediapicker.ConversationMediaPickerDelegate import com.android.messaging.ui.conversation.v2.mediapicker.ConversationMediaPickerDelegateImpl import com.android.messaging.ui.conversation.v2.messages.delegate.ConversationMessageSelectionDelegate @@ -68,6 +70,12 @@ internal abstract class ConversationViewModelBindsModule { impl: ConversationMetadataDelegateImpl, ): ConversationMetadataDelegate + @Binds + @ViewModelScoped + abstract fun bindConversationFocusDelegate( + impl: ConversationFocusDelegateImpl, + ): ConversationFocusDelegate + @Binds @ViewModelScoped abstract fun bindRecipientPickerDelegate( diff --git a/src/com/android/messaging/ui/conversation/v2/ConversationActivity.kt b/src/com/android/messaging/ui/conversation/v2/ConversationActivity.kt index f3d78357..9a0220f9 100644 --- a/src/com/android/messaging/ui/conversation/v2/ConversationActivity.kt +++ b/src/com/android/messaging/ui/conversation/v2/ConversationActivity.kt @@ -88,6 +88,7 @@ internal class ConversationActivity : ComponentActivity() { startupAttachmentType = intent .getStringExtra(UIIntents.UI_INTENT_EXTRA_ATTACHMENT_TYPE) ?.takeUnless(TextUtils::isEmpty), + isLaunchedFromBubble = isLaunchedFromBubble, ) intent.removeExtra(UIIntents.UI_INTENT_EXTRA_DRAFT_DATA) diff --git a/src/com/android/messaging/ui/conversation/v2/entry/model/ConversationEntryLaunchRequest.kt b/src/com/android/messaging/ui/conversation/v2/entry/model/ConversationEntryLaunchRequest.kt index 76e0f037..30db096e 100644 --- a/src/com/android/messaging/ui/conversation/v2/entry/model/ConversationEntryLaunchRequest.kt +++ b/src/com/android/messaging/ui/conversation/v2/entry/model/ConversationEntryLaunchRequest.kt @@ -10,4 +10,5 @@ internal data class ConversationEntryLaunchRequest( val draftData: MessageData? = null, val startupAttachmentUri: String? = null, val startupAttachmentType: String? = null, + val isLaunchedFromBubble: Boolean = false, ) diff --git a/src/com/android/messaging/ui/conversation/v2/focus/delegate/ConversationFocusDelegate.kt b/src/com/android/messaging/ui/conversation/v2/focus/delegate/ConversationFocusDelegate.kt new file mode 100644 index 00000000..0fe3456f --- /dev/null +++ b/src/com/android/messaging/ui/conversation/v2/focus/delegate/ConversationFocusDelegate.kt @@ -0,0 +1,107 @@ +package com.android.messaging.ui.conversation.v2.focus.delegate + +import com.android.messaging.datamodel.BugleNotifications +import com.android.messaging.datamodel.DataModel +import com.android.messaging.di.core.DefaultDispatcher +import javax.inject.Inject +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.launch + +internal interface ConversationFocusDelegate { + fun bind( + scope: CoroutineScope, + conversationIdFlow: StateFlow, + ) + + fun setScreenFocused( + focused: Boolean, + cancelNotification: Boolean = true, + ) +} + +internal class ConversationFocusDelegateImpl @Inject constructor( + @param:DefaultDispatcher + private val defaultDispatcher: CoroutineDispatcher, +) : ConversationFocusDelegate { + + private val focusStateFlow = MutableStateFlow(value = FocusRequest.Unfocused) + + private var boundScope: CoroutineScope? = null + + override fun bind( + scope: CoroutineScope, + conversationIdFlow: StateFlow, + ) { + if (boundScope != null) { + return + } + + boundScope = scope + + scope.launch(defaultDispatcher) { + combine( + focusStateFlow, + conversationIdFlow, + ) { focusRequest, conversationId -> + when (focusRequest) { + is FocusRequest.Focused -> { + conversationId + ?.takeIf { it.isNotBlank() } + ?.let { id -> + FocusedConversation( + conversationId = id, + cancelNotification = focusRequest.cancelNotification, + ) + } + } + + FocusRequest.Unfocused -> null + } + } + .distinctUntilChanged() + .collect { focused -> + when { + focused == null -> { + DataModel.get().setFocusedConversation(null) + } + + else -> { + DataModel.get().setFocusedConversation(focused.conversationId) + + BugleNotifications.markMessagesAsRead( + focused.conversationId, + focused.cancelNotification, + ) + } + } + } + } + } + + override fun setScreenFocused( + focused: Boolean, + cancelNotification: Boolean, + ) { + focusStateFlow.value = when { + focused -> FocusRequest.Focused(cancelNotification = cancelNotification) + else -> FocusRequest.Unfocused + } + } + + private sealed interface FocusRequest { + data object Unfocused : FocusRequest + data class Focused( + val cancelNotification: Boolean, + ) : FocusRequest + } + + private data class FocusedConversation( + val conversationId: String, + val cancelNotification: Boolean, + ) +} diff --git a/src/com/android/messaging/ui/conversation/v2/navigation/ConversationNavGraph.kt b/src/com/android/messaging/ui/conversation/v2/navigation/ConversationNavGraph.kt index 4b13fb76..bd03a35d 100644 --- a/src/com/android/messaging/ui/conversation/v2/navigation/ConversationNavGraph.kt +++ b/src/com/android/messaging/ui/conversation/v2/navigation/ConversationNavGraph.kt @@ -43,6 +43,9 @@ internal fun ConversationNavGraph( val latestNavigationReducer = rememberUpdatedState(navigationReducer) val latestOnConversationDetailsClick = rememberUpdatedState(onConversationDetailsClick) val latestOnFinish = rememberUpdatedState(onFinish) + val latestIsLaunchedFromBubble = rememberUpdatedState( + launchRequest?.isLaunchedFromBubble == true, + ) val entryDecorators = listOf( rememberSaveableStateHolderNavEntryDecorator(), @@ -61,6 +64,7 @@ internal fun ConversationNavGraph( ConversationScreen( conversationId = navKey.conversationId, launchGeneration = currentEntryUiState.launchGeneration, + cancelIncomingNotification = !latestIsLaunchedFromBubble.value, onAddPeopleClick = { latestNavigationReducer.value.navigateToAddParticipants( backStack = backStack, diff --git a/src/com/android/messaging/ui/conversation/v2/screen/ConversationScreen.kt b/src/com/android/messaging/ui/conversation/v2/screen/ConversationScreen.kt index 60c9ae2f..ec541392 100644 --- a/src/com/android/messaging/ui/conversation/v2/screen/ConversationScreen.kt +++ b/src/com/android/messaging/ui/conversation/v2/screen/ConversationScreen.kt @@ -68,6 +68,7 @@ internal fun ConversationScreen( modifier: Modifier = Modifier, conversationId: String? = null, launchGeneration: Int? = null, + cancelIncomingNotification: Boolean = true, onAddPeopleClick: () -> Unit, onConversationDetailsClick: () -> Unit, onNavigateBack: () -> Unit, @@ -178,6 +179,14 @@ internal fun ConversationScreen( permissionState = permissionState, ) + LifecycleEventEffect(event = Lifecycle.Event.ON_RESUME) { + screenModel.onScreenForegrounded(cancelNotification = cancelIncomingNotification) + } + + LifecycleEventEffect(event = Lifecycle.Event.ON_PAUSE) { + screenModel.onScreenBackgrounded() + } + LifecycleEventEffect(event = Lifecycle.Event.ON_STOP) { val isRecording = scaffoldUiState.composer.audioRecording.phase == ConversationAudioRecordingPhase.Recording diff --git a/src/com/android/messaging/ui/conversation/v2/screen/ConversationViewModel.kt b/src/com/android/messaging/ui/conversation/v2/screen/ConversationViewModel.kt index 02dcf039..63a21f0d 100644 --- a/src/com/android/messaging/ui/conversation/v2/screen/ConversationViewModel.kt +++ b/src/com/android/messaging/ui/conversation/v2/screen/ConversationViewModel.kt @@ -17,6 +17,7 @@ import com.android.messaging.ui.conversation.v2.composer.mapper.ConversationComp import com.android.messaging.ui.conversation.v2.composer.model.ComposerAttachmentUiModel import com.android.messaging.ui.conversation.v2.composer.model.ConversationComposerUiState import com.android.messaging.ui.conversation.v2.entry.model.ConversationEntryStartupAttachment +import com.android.messaging.ui.conversation.v2.focus.delegate.ConversationFocusDelegate import com.android.messaging.ui.conversation.v2.mediapicker.ConversationMediaPickerDelegate import com.android.messaging.ui.conversation.v2.mediapicker.model.ConversationCapturedMedia import com.android.messaging.ui.conversation.v2.messages.delegate.ConversationMessageSelectionDelegate @@ -106,6 +107,9 @@ internal interface ConversationScreenModel { fun onDeleteConversationClick() fun confirmDeleteConversation() fun dismissDeleteConversationConfirmation() + + fun onScreenForegrounded(cancelNotification: Boolean) + fun onScreenBackgrounded() } @HiltViewModel @@ -117,6 +121,7 @@ internal class ConversationViewModel @Inject constructor( private val conversationMessageSelectionDelegate: ConversationMessageSelectionDelegate, private val conversationMediaPickerDelegate: ConversationMediaPickerDelegate, private val conversationMetadataDelegate: ConversationMetadataDelegate, + private val conversationFocusDelegate: ConversationFocusDelegate, private val conversationComposerUiStateMapper: ConversationComposerUiStateMapper, private val conversationSubscriptionsRepository: ConversationSubscriptionsRepository, private val canAddMoreConversationParticipants: CanAddMoreConversationParticipants, @@ -291,6 +296,10 @@ internal class ConversationViewModel @Inject constructor( scope = viewModelScope, conversationIdFlow = conversationIdFlow, ) + conversationFocusDelegate.bind( + scope = viewModelScope, + conversationIdFlow = conversationIdFlow, + ) bindDelegateEffects() } @@ -590,7 +599,19 @@ internal class ConversationViewModel @Inject constructor( conversationMetadataDelegate.dismissDeleteConversationConfirmation() } + override fun onScreenForegrounded(cancelNotification: Boolean) { + conversationFocusDelegate.setScreenFocused( + focused = true, + cancelNotification = cancelNotification, + ) + } + + override fun onScreenBackgrounded() { + conversationFocusDelegate.setScreenFocused(focused = false) + } + override fun onCleared() { + conversationFocusDelegate.setScreenFocused(focused = false) conversationAudioRecordingDelegate.onScreenCleared() conversationMediaPickerDelegate.onScreenCleared() conversationDraftDelegate.flushDraft() From 94740100c23450a51fa150a6b4cf7c8337cb3d51 Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Mon, 27 Apr 2026 16:06:04 +0300 Subject: [PATCH 60/99] Add ability to download attachments --- res/values/strings.xml | 9 + .../ConversationAttachmentRepository.kt | 180 +++++++++++++++++- .../repository/SaveAttachmentsResult.kt | 8 + .../ConversationMessageSelectionDelegate.kt | 53 ++++++ .../ConversationMessageUiModelMapper.kt | 15 ++ .../message/ConversationMessageUiModel.kt | 1 + .../v2/screen/ConversationScreenEffects.kt | 44 +++++ .../screen/ConversationSelectionTopAppBar.kt | 13 ++ .../ConversationMessageSelectionUiState.kt | 1 + .../screen/model/ConversationScreenEffect.kt | 7 + 10 files changed, 330 insertions(+), 1 deletion(-) create mode 100644 src/com/android/messaging/ui/conversation/v2/mediapicker/repository/SaveAttachmentsResult.kt diff --git a/res/values/strings.xml b/res/values/strings.xml index e1717eef..9c3e7192 100644 --- a/res/values/strings.xml +++ b/res/values/strings.xml @@ -86,6 +86,7 @@ Click to open contacts list on this device Share + Save attachment "Just now" "Now" @@ -452,6 +453,14 @@ %d attachment saved to \"Downloads\" %d attachments saved to \"Downloads\" + + %d photo saved + %d photos saved + + + %d video saved + %d videos saved + %d attachment saved %d attachments saved diff --git a/src/com/android/messaging/ui/conversation/v2/mediapicker/repository/ConversationAttachmentRepository.kt b/src/com/android/messaging/ui/conversation/v2/mediapicker/repository/ConversationAttachmentRepository.kt index ab47d047..28907b9e 100644 --- a/src/com/android/messaging/ui/conversation/v2/mediapicker/repository/ConversationAttachmentRepository.kt +++ b/src/com/android/messaging/ui/conversation/v2/mediapicker/repository/ConversationAttachmentRepository.kt @@ -1,8 +1,12 @@ package com.android.messaging.ui.conversation.v2.mediapicker.repository import android.content.ContentResolver +import android.content.ContentValues import android.net.Uri +import android.os.Environment import android.provider.ContactsContract.Contacts +import android.provider.MediaStore +import android.webkit.MimeTypeMap import androidx.core.database.getStringOrNull import androidx.core.net.toUri import com.android.messaging.data.conversation.model.draft.ConversationDraftAttachment @@ -12,7 +16,7 @@ import com.android.messaging.util.ContentType import com.android.messaging.util.LogUtil import com.android.messaging.util.core.extension.typedFlow import com.android.messaging.util.core.extension.unitFlow -import com.android.messaging.util.db.ext.getStringOrNull +import java.io.IOException import javax.inject.Inject import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineDispatcher @@ -28,6 +32,15 @@ internal interface ConversationAttachmentRepository { fun deleteTemporaryAttachment( contentUri: String, ): Flow + + fun saveAttachmentsToMediaStore( + attachments: List, + ): Flow + + data class AttachmentToSave( + val contentType: String, + val contentUri: String, + ) } internal class ConversationAttachmentRepositoryImpl @Inject constructor( @@ -71,6 +84,161 @@ internal class ConversationAttachmentRepositoryImpl @Inject constructor( }.flowOn(ioDispatcher) } + override fun saveAttachmentsToMediaStore( + attachments: List, + ): Flow { + return typedFlow { + saveAttachments(attachments = attachments) + }.flowOn(ioDispatcher) + } + + private fun saveAttachments( + attachments: List, + ): SaveAttachmentsResult { + var imageCount = 0 + var videoCount = 0 + var otherCount = 0 + var failCount = 0 + + for (attachment in attachments) { + val target = mediaStoreTarget(contentType = attachment.contentType) + val saved = saveOne( + sourceUri = attachment.contentUri.toUri(), + contentType = attachment.contentType, + target = target, + ) + + if (!saved) { + failCount++ + continue + } + + when (target.kind) { + MediaKind.Image -> imageCount++ + MediaKind.Video -> videoCount++ + MediaKind.Audio, + MediaKind.Other, + -> otherCount++ + } + } + + return SaveAttachmentsResult( + imageCount = imageCount, + videoCount = videoCount, + otherCount = otherCount, + failCount = failCount, + ) + } + + private fun saveOne( + sourceUri: Uri, + contentType: String, + target: MediaStoreTarget, + ): Boolean { + val pendingUri = insertPendingRow( + contentType = contentType, + target = target, + ) ?: return false + + val copied = copyToPending( + sourceUri = sourceUri, + pendingUri = pendingUri, + ) + + if (copied) { + finalizePendingRow(pendingUri = pendingUri) + } else { + deletePendingRow(pendingUri = pendingUri) + } + + return copied + } + + private fun insertPendingRow( + contentType: String, + target: MediaStoreTarget, + ): Uri? { + val values = ContentValues().apply { + put(MediaStore.MediaColumns.DISPLAY_NAME, buildDisplayName(contentType = contentType)) + put(MediaStore.MediaColumns.MIME_TYPE, contentType) + put(MediaStore.MediaColumns.RELATIVE_PATH, target.relativePath) + put(MediaStore.MediaColumns.IS_PENDING, 1) + } + + return try { + contentResolver.insert(target.collection, values) + } catch (e: Exception) { + LogUtil.e(TAG, "MediaStore insert failed for $contentType", e) + null + } + } + + private fun copyToPending( + sourceUri: Uri, + pendingUri: Uri, + ): Boolean { + return try { + contentResolver.openInputStream(sourceUri)?.use { source -> + contentResolver.openOutputStream(pendingUri)?.use { sink -> + source.copyTo(sink) + true + } + } ?: false + } catch (e: IOException) { + LogUtil.e(TAG, "Copy to MediaStore failed for $sourceUri", e) + false + } catch (e: SecurityException) { + LogUtil.e(TAG, "Copy to MediaStore denied for $sourceUri", e) + false + } + } + + private fun buildDisplayName(contentType: String): String { + val extension = MimeTypeMap.getSingleton().getExtensionFromMimeType(contentType) ?: "bin" + return "${System.currentTimeMillis()}.$extension" + } + + private fun mediaStoreTarget(contentType: String): MediaStoreTarget { + val volume = MediaStore.VOLUME_EXTERNAL_PRIMARY + + return when { + ContentType.isImageType(contentType) -> MediaStoreTarget( + collection = MediaStore.Images.Media.getContentUri(volume), + relativePath = Environment.DIRECTORY_PICTURES + "/" + SAVED_ATTACHMENTS_FOLDER, + kind = MediaKind.Image, + ) + + ContentType.isVideoType(contentType) -> MediaStoreTarget( + collection = MediaStore.Video.Media.getContentUri(volume), + relativePath = Environment.DIRECTORY_PICTURES + "/" + SAVED_ATTACHMENTS_FOLDER, + kind = MediaKind.Video, + ) + + ContentType.isAudioType(contentType) -> MediaStoreTarget( + collection = MediaStore.Audio.Media.getContentUri(volume), + relativePath = Environment.DIRECTORY_MUSIC + "/" + SAVED_ATTACHMENTS_FOLDER, + kind = MediaKind.Audio, + ) + + else -> MediaStoreTarget( + collection = MediaStore.Downloads.getContentUri(volume), + relativePath = Environment.DIRECTORY_DOWNLOADS, + kind = MediaKind.Other, + ) + } + } + + private fun deletePendingRow(pendingUri: Uri) { + runCatching { contentResolver.delete(pendingUri, null, null) } + } + + private fun finalizePendingRow(pendingUri: Uri) { + val values = ContentValues().apply { + put(MediaStore.MediaColumns.IS_PENDING, 0) + } + runCatching { contentResolver.update(pendingUri, values, null, null) } + } + private fun queryDraftAttachmentFromContact( contactUri: String, ): ConversationDraftAttachment? { @@ -107,5 +275,15 @@ internal class ConversationAttachmentRepositoryImpl @Inject constructor( private companion object { private const val TAG = "ConversationAttachmentRepository" + + private const val SAVED_ATTACHMENTS_FOLDER = "Messaging" } } + +private enum class MediaKind { Image, Video, Audio, Other } + +private data class MediaStoreTarget( + val collection: Uri, + val relativePath: String, + val kind: MediaKind, +) diff --git a/src/com/android/messaging/ui/conversation/v2/mediapicker/repository/SaveAttachmentsResult.kt b/src/com/android/messaging/ui/conversation/v2/mediapicker/repository/SaveAttachmentsResult.kt new file mode 100644 index 00000000..37d987cf --- /dev/null +++ b/src/com/android/messaging/ui/conversation/v2/mediapicker/repository/SaveAttachmentsResult.kt @@ -0,0 +1,8 @@ +package com.android.messaging.ui.conversation.v2.mediapicker.repository + +internal data class SaveAttachmentsResult( + val imageCount: Int, + val videoCount: Int, + val otherCount: Int, + val failCount: Int, +) diff --git a/src/com/android/messaging/ui/conversation/v2/messages/delegate/ConversationMessageSelectionDelegate.kt b/src/com/android/messaging/ui/conversation/v2/messages/delegate/ConversationMessageSelectionDelegate.kt index 359a4489..6e407ee1 100644 --- a/src/com/android/messaging/ui/conversation/v2/messages/delegate/ConversationMessageSelectionDelegate.kt +++ b/src/com/android/messaging/ui/conversation/v2/messages/delegate/ConversationMessageSelectionDelegate.kt @@ -6,6 +6,7 @@ import com.android.messaging.data.conversation.repository.ConversationsRepositor import com.android.messaging.di.core.DefaultDispatcher import com.android.messaging.domain.conversation.usecase.CreateForwardedMessage import com.android.messaging.ui.conversation.v2.common.ConversationScreenDelegate +import com.android.messaging.ui.conversation.v2.mediapicker.repository.ConversationAttachmentRepository import com.android.messaging.ui.conversation.v2.messages.model.message.ConversationMessagePartUiModel import com.android.messaging.ui.conversation.v2.messages.model.message.ConversationMessageUiModel import com.android.messaging.ui.conversation.v2.messages.model.message.ConversationMessagesUiState @@ -48,6 +49,7 @@ internal interface ConversationMessageSelectionDelegate : internal class ConversationMessageSelectionDelegateImpl @Inject constructor( private val clipboardManager: ClipboardManager, + private val conversationAttachmentRepository: ConversationAttachmentRepository, private val conversationMessagesDelegate: ConversationMessagesDelegate, private val createForwardedMessage: CreateForwardedMessage, private val conversationsRepository: ConversationsRepository, @@ -121,6 +123,10 @@ internal class ConversationMessageSelectionDelegateImpl @Inject constructor( resendSelectedMessage() } + ConversationMessageSelectionAction.SaveAttachment -> { + saveSelectedMessageAttachments() + } + ConversationMessageSelectionAction.Share -> { shareSelectedMessage() } @@ -290,6 +296,49 @@ internal class ConversationMessageSelectionDelegateImpl @Inject constructor( } } + private fun saveSelectedMessageAttachments() { + val selectedMessage = singleSelectedMessageOrNull() ?: return + + val attachments = selectedMessage.parts + .asSequence() + .filterIsInstance() + .filterNot { it.contentType.isBlank() } + .mapNotNull { attachment -> + when (val contentUri = attachment.contentUri) { + null -> null + + else -> { + ConversationAttachmentRepository.AttachmentToSave( + contentType = attachment.contentType, + contentUri = contentUri.toString(), + ) + } + } + } + .toList() + + clearMessageSelection() + + if (attachments.isEmpty()) { + return + } + + boundScope?.launch(defaultDispatcher) { + conversationAttachmentRepository + .saveAttachmentsToMediaStore(attachments = attachments) + .collect { result -> + _effects.emit( + ConversationScreenEffect.ShowSaveAttachmentsResult( + imageCount = result.imageCount, + videoCount = result.videoCount, + otherCount = result.otherCount, + failCount = result.failCount, + ), + ) + } + } + } + private fun shareSelectedMessage() { val selectedMessage = singleSelectedMessageOrNull() ?: return val messageText = selectedMessage.text?.takeIf(String::isNotBlank) @@ -422,6 +471,10 @@ private fun availableSelectionActions( actions += ConversationMessageSelectionAction.Forward } + if (selectedMessage.canSaveAttachments) { + actions += ConversationMessageSelectionAction.SaveAttachment + } + if (selectedMessage.canCopyMessageToClipboard) { actions += ConversationMessageSelectionAction.Copy } diff --git a/src/com/android/messaging/ui/conversation/v2/messages/mapper/ConversationMessageUiModelMapper.kt b/src/com/android/messaging/ui/conversation/v2/messages/mapper/ConversationMessageUiModelMapper.kt index 5e3a5650..330f3f33 100644 --- a/src/com/android/messaging/ui/conversation/v2/messages/mapper/ConversationMessageUiModelMapper.kt +++ b/src/com/android/messaging/ui/conversation/v2/messages/mapper/ConversationMessageUiModelMapper.kt @@ -42,11 +42,26 @@ internal class ConversationMessageUiModelMapperImpl @Inject constructor( canDownloadMessage = data.showDownloadMessage, canForwardMessage = data.canForwardMessage, canResendMessage = data.showResendMessage, + canSaveAttachments = canSaveAttachments(data), mmsSubject = data.mmsSubject, protocol = mapProtocol(data), ) } + private fun canSaveAttachments(data: ConversationMessageData): Boolean { + return when (val parts = data.parts) { + null -> false + + else -> { + parts.any { part -> + !part.contentType.isNullOrBlank() && + part.contentUri != null && + !ContentType.isTextType(part.contentType) + } + } + } + } + private fun mapPart(part: MessagePartData): ConversationMessagePartUiModel { val contentType = part.contentType ?: "" diff --git a/src/com/android/messaging/ui/conversation/v2/messages/model/message/ConversationMessageUiModel.kt b/src/com/android/messaging/ui/conversation/v2/messages/model/message/ConversationMessageUiModel.kt index b526b19f..1b65a5fb 100644 --- a/src/com/android/messaging/ui/conversation/v2/messages/model/message/ConversationMessageUiModel.kt +++ b/src/com/android/messaging/ui/conversation/v2/messages/model/message/ConversationMessageUiModel.kt @@ -23,6 +23,7 @@ internal data class ConversationMessageUiModel( val canDownloadMessage: Boolean, val canForwardMessage: Boolean, val canResendMessage: Boolean, + val canSaveAttachments: Boolean, val mmsSubject: String?, val protocol: Protocol, ) { diff --git a/src/com/android/messaging/ui/conversation/v2/screen/ConversationScreenEffects.kt b/src/com/android/messaging/ui/conversation/v2/screen/ConversationScreenEffects.kt index 2c22cb5e..2d226311 100644 --- a/src/com/android/messaging/ui/conversation/v2/screen/ConversationScreenEffects.kt +++ b/src/com/android/messaging/ui/conversation/v2/screen/ConversationScreenEffects.kt @@ -77,6 +77,13 @@ internal fun ConversationScreenEffects( ) } + is ConversationScreenEffect.ShowSaveAttachmentsResult -> { + showSaveAttachmentsResultToast( + context = context, + effect = effect, + ) + } + is ConversationScreenEffect.LaunchForwardMessage -> { UIIntents.get().launchForwardMessageActivity( context, @@ -128,6 +135,43 @@ private fun placePhoneCall( ) } +private fun showSaveAttachmentsResultToast( + context: Context, + effect: ConversationScreenEffect.ShowSaveAttachmentsResult, +) { + if (effect.failCount > 0) { + UiUtils.showToastAtBottom( + context.resources.getQuantityString( + R.plurals.attachment_save_error, + effect.failCount, + effect.failCount, + ), + ) + + return + } + + val total = effect.imageCount + effect.videoCount + effect.otherCount + if (total == 0) { + return + } + + val pluralResId = when { + effect.otherCount > 0 && effect.imageCount + effect.videoCount == 0 -> { + R.plurals.attachments_saved_to_downloads + } + + effect.otherCount > 0 -> R.plurals.attachments_saved + effect.videoCount == 0 -> R.plurals.photos_saved + effect.imageCount == 0 -> R.plurals.videos_saved + else -> R.plurals.attachments_saved + } + + UiUtils.showToastAtBottom( + context.resources.getQuantityString(pluralResId, total, total), + ) +} + private suspend fun openShareSheet( context: Context, attachmentContentType: String?, diff --git a/src/com/android/messaging/ui/conversation/v2/screen/ConversationSelectionTopAppBar.kt b/src/com/android/messaging/ui/conversation/v2/screen/ConversationSelectionTopAppBar.kt index 63114790..a8b9fae3 100644 --- a/src/com/android/messaging/ui/conversation/v2/screen/ConversationSelectionTopAppBar.kt +++ b/src/com/android/messaging/ui/conversation/v2/screen/ConversationSelectionTopAppBar.kt @@ -9,6 +9,7 @@ import androidx.compose.material.icons.rounded.Delete import androidx.compose.material.icons.rounded.FileDownload import androidx.compose.material.icons.rounded.Info import androidx.compose.material.icons.rounded.MoreVert +import androidx.compose.material.icons.rounded.Save import androidx.compose.material.icons.rounded.Share import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem @@ -53,6 +54,14 @@ internal fun ConversationSelectionTopAppBar( add(ConversationMessageSelectionAction.Forward) } + val hasSaveAttachmentAction = selection.availableActions.contains( + ConversationMessageSelectionAction.SaveAttachment, + ) + + if (hasSaveAttachmentAction) { + add(ConversationMessageSelectionAction.SaveAttachment) + } + if (selection.availableActions.contains(ConversationMessageSelectionAction.Details)) { add(ConversationMessageSelectionAction.Details) } @@ -191,6 +200,7 @@ private fun selectionActionIcon( ConversationMessageSelectionAction.Download -> Icons.Rounded.FileDownload ConversationMessageSelectionAction.Forward -> Icons.AutoMirrored.Rounded.Forward ConversationMessageSelectionAction.Resend -> Icons.AutoMirrored.Rounded.Send + ConversationMessageSelectionAction.SaveAttachment -> Icons.Rounded.Save ConversationMessageSelectionAction.Share -> Icons.Rounded.Share } } @@ -218,6 +228,9 @@ private fun selectionActionLabel( ConversationMessageSelectionAction.Resend -> { stringResource(R.string.action_send) } + ConversationMessageSelectionAction.SaveAttachment -> { + stringResource(R.string.action_save_attachment) + } ConversationMessageSelectionAction.Share -> { stringResource(R.string.action_share) } diff --git a/src/com/android/messaging/ui/conversation/v2/screen/model/ConversationMessageSelectionUiState.kt b/src/com/android/messaging/ui/conversation/v2/screen/model/ConversationMessageSelectionUiState.kt index 24fb2b08..157d789d 100644 --- a/src/com/android/messaging/ui/conversation/v2/screen/model/ConversationMessageSelectionUiState.kt +++ b/src/com/android/messaging/ui/conversation/v2/screen/model/ConversationMessageSelectionUiState.kt @@ -32,5 +32,6 @@ internal enum class ConversationMessageSelectionAction { Download, Forward, Resend, + SaveAttachment, Share, } diff --git a/src/com/android/messaging/ui/conversation/v2/screen/model/ConversationScreenEffect.kt b/src/com/android/messaging/ui/conversation/v2/screen/model/ConversationScreenEffect.kt index 1a9c4f2d..19591558 100644 --- a/src/com/android/messaging/ui/conversation/v2/screen/model/ConversationScreenEffect.kt +++ b/src/com/android/messaging/ui/conversation/v2/screen/model/ConversationScreenEffect.kt @@ -30,6 +30,13 @@ internal sealed interface ConversationScreenEffect { val phoneNumber: String, ) : ConversationScreenEffect + data class ShowSaveAttachmentsResult( + val imageCount: Int, + val videoCount: Int, + val otherCount: Int, + val failCount: Int, + ) : ConversationScreenEffect + data class ShareMessage( val attachmentContentType: String?, val attachmentContentUri: String?, From be758ee1114c42647888601064d380a6f6ff16cd Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Mon, 27 Apr 2026 17:15:19 +0300 Subject: [PATCH 61/99] Scroll to a message index from Intent on Compose screen --- .../conversation/v2/ConversationActivity.kt | 4 ++ .../v2/entry/ConversationEntryViewModel.kt | 27 ++++++- .../model/ConversationEntryLaunchRequest.kt | 1 + .../entry/model/ConversationEntryUiState.kt | 1 + .../v2/navigation/ConversationNavGraph.kt | 19 +++++ .../v2/screen/ConversationScreen.kt | 71 +++++++++++++++++++ 6 files changed, 120 insertions(+), 3 deletions(-) diff --git a/src/com/android/messaging/ui/conversation/v2/ConversationActivity.kt b/src/com/android/messaging/ui/conversation/v2/ConversationActivity.kt index 9a0220f9..25d9fb70 100644 --- a/src/com/android/messaging/ui/conversation/v2/ConversationActivity.kt +++ b/src/com/android/messaging/ui/conversation/v2/ConversationActivity.kt @@ -88,10 +88,14 @@ internal class ConversationActivity : ComponentActivity() { startupAttachmentType = intent .getStringExtra(UIIntents.UI_INTENT_EXTRA_ATTACHMENT_TYPE) ?.takeUnless(TextUtils::isEmpty), + messagePosition = intent + .getIntExtra(UIIntents.UI_INTENT_EXTRA_MESSAGE_POSITION, -1) + .takeIf { position -> position >= 0 }, isLaunchedFromBubble = isLaunchedFromBubble, ) intent.removeExtra(UIIntents.UI_INTENT_EXTRA_DRAFT_DATA) + intent.removeExtra(UIIntents.UI_INTENT_EXTRA_MESSAGE_POSITION) return false } diff --git a/src/com/android/messaging/ui/conversation/v2/entry/ConversationEntryViewModel.kt b/src/com/android/messaging/ui/conversation/v2/entry/ConversationEntryViewModel.kt index d5a0eff8..5b4f975c 100644 --- a/src/com/android/messaging/ui/conversation/v2/entry/ConversationEntryViewModel.kt +++ b/src/com/android/messaging/ui/conversation/v2/entry/ConversationEntryViewModel.kt @@ -46,6 +46,8 @@ internal interface ConversationEntryModel { fun onDraftPayloadConsumed(conversationId: String) + fun onScrollPositionConsumed(conversationId: String) + fun onStartupAttachmentConsumed(conversationId: String) fun navigateBack() @@ -203,6 +205,7 @@ internal class ConversationEntryViewModel @Inject constructor( pendingDraft = launchRequest.draftData?.let { messageData -> conversationMessageDataDraftMapper.map(messageData = messageData) }, + pendingScrollPosition = launchRequest.messagePosition, pendingStartupAttachment = buildStartupAttachmentOrNull( contentUri = launchRequest.startupAttachmentUri, contentType = launchRequest.startupAttachmentType, @@ -210,6 +213,7 @@ internal class ConversationEntryViewModel @Inject constructor( ), ) savedStateHandle[PENDING_DRAFT_DATA_KEY] = launchRequest.draftData + savedStateHandle[PENDING_SCROLL_POSITION_KEY] = launchRequest.messagePosition savedStateHandle[PROCESSED_LAUNCH_GENERATION_KEY] = launchRequest.launchGeneration } @@ -241,12 +245,27 @@ internal class ConversationEntryViewModel @Inject constructor( } } + override fun onScrollPositionConsumed(conversationId: String) { + val currentUiState = _uiState.value + + val hasPendingScrollPosition = currentUiState.pendingScrollPosition != null + + if (currentUiState.conversationId == conversationId && hasPendingScrollPosition) { + updateUiState( + currentUiState.copy( + pendingScrollPosition = null, + ), + ) + savedStateHandle[PENDING_SCROLL_POSITION_KEY] = null + } + } + override fun onStartupAttachmentConsumed(conversationId: String) { val currentUiState = _uiState.value - if (currentUiState.conversationId == conversationId && - currentUiState.pendingStartupAttachment != null - ) { + val hasPendingStartupAttachment = currentUiState.pendingStartupAttachment != null + + if (currentUiState.conversationId == conversationId && hasPendingStartupAttachment) { updateUiState( currentUiState.copy( pendingStartupAttachment = null, @@ -307,6 +326,7 @@ internal class ConversationEntryViewModel @Inject constructor( pendingDraft = pendingDraftData?.let { messageData -> conversationMessageDataDraftMapper.map(messageData = messageData) }, + pendingScrollPosition = savedStateHandle[PENDING_SCROLL_POSITION_KEY], pendingStartupAttachment = buildStartupAttachmentOrNull( contentUri = startupAttachmentUri, contentType = startupAttachmentType, @@ -465,6 +485,7 @@ internal class ConversationEntryViewModel @Inject constructor( private const val IS_CREATING_GROUP_KEY = "is_creating_group" private const val LAUNCH_GENERATION_KEY = "launch_generation" private const val PENDING_DRAFT_DATA_KEY = "pending_draft_data" + private const val PENDING_SCROLL_POSITION_KEY = "pending_scroll_position" private const val PENDING_STARTUP_ATTACHMENT_TYPE_KEY = "pending_startup_attachment_type" private const val PENDING_STARTUP_ATTACHMENT_URI_KEY = "pending_startup_attachment_uri" diff --git a/src/com/android/messaging/ui/conversation/v2/entry/model/ConversationEntryLaunchRequest.kt b/src/com/android/messaging/ui/conversation/v2/entry/model/ConversationEntryLaunchRequest.kt index 30db096e..9777b1be 100644 --- a/src/com/android/messaging/ui/conversation/v2/entry/model/ConversationEntryLaunchRequest.kt +++ b/src/com/android/messaging/ui/conversation/v2/entry/model/ConversationEntryLaunchRequest.kt @@ -10,5 +10,6 @@ internal data class ConversationEntryLaunchRequest( val draftData: MessageData? = null, val startupAttachmentUri: String? = null, val startupAttachmentType: String? = null, + val messagePosition: Int? = null, val isLaunchedFromBubble: Boolean = false, ) diff --git a/src/com/android/messaging/ui/conversation/v2/entry/model/ConversationEntryUiState.kt b/src/com/android/messaging/ui/conversation/v2/entry/model/ConversationEntryUiState.kt index e1598a5f..14c9abab 100644 --- a/src/com/android/messaging/ui/conversation/v2/entry/model/ConversationEntryUiState.kt +++ b/src/com/android/messaging/ui/conversation/v2/entry/model/ConversationEntryUiState.kt @@ -13,6 +13,7 @@ internal data class ConversationEntryUiState( val isResolvingConversation: Boolean = false, val isResolvingConversationIndicatorVisible: Boolean = false, val pendingDraft: ConversationDraft? = null, + val pendingScrollPosition: Int? = null, val pendingStartupAttachment: ConversationEntryStartupAttachment? = null, val resolvingRecipientDestination: String? = null, val selectedGroupRecipientDestinations: ImmutableList = persistentListOf(), diff --git a/src/com/android/messaging/ui/conversation/v2/navigation/ConversationNavGraph.kt b/src/com/android/messaging/ui/conversation/v2/navigation/ConversationNavGraph.kt index bd03a35d..d6064f94 100644 --- a/src/com/android/messaging/ui/conversation/v2/navigation/ConversationNavGraph.kt +++ b/src/com/android/messaging/ui/conversation/v2/navigation/ConversationNavGraph.kt @@ -85,6 +85,10 @@ internal fun ConversationNavGraph( entryUiState = currentEntryUiState, conversationId = navKey.conversationId, ), + pendingScrollPosition = pendingScrollPositionForConversation( + entryUiState = currentEntryUiState, + conversationId = navKey.conversationId, + ), pendingStartupAttachment = pendingStartupAttachmentForConversation( entryUiState = currentEntryUiState, conversationId = navKey.conversationId, @@ -94,6 +98,11 @@ internal fun ConversationNavGraph( conversationId = navKey.conversationId, ) }, + onPendingScrollPositionConsumed = { + currentEntryModel.onScrollPositionConsumed( + conversationId = navKey.conversationId, + ) + }, onPendingStartupAttachmentConsumed = { currentEntryModel.onStartupAttachmentConsumed( conversationId = navKey.conversationId, @@ -275,6 +284,16 @@ private fun handleNewChatBack( ) } +private fun pendingScrollPositionForConversation( + entryUiState: ConversationEntryUiState, + conversationId: String, +): Int? { + return when { + entryUiState.conversationId == conversationId -> entryUiState.pendingScrollPosition + else -> null + } +} + private fun pendingStartupAttachmentForConversation( entryUiState: ConversationEntryUiState, conversationId: String, diff --git a/src/com/android/messaging/ui/conversation/v2/screen/ConversationScreen.kt b/src/com/android/messaging/ui/conversation/v2/screen/ConversationScreen.kt index ec541392..4be1b6c6 100644 --- a/src/com/android/messaging/ui/conversation/v2/screen/ConversationScreen.kt +++ b/src/com/android/messaging/ui/conversation/v2/screen/ConversationScreen.kt @@ -57,6 +57,8 @@ import com.android.messaging.ui.conversation.v2.screen.model.ConversationMessage import com.android.messaging.ui.conversation.v2.screen.model.ConversationScreenScaffoldUiState import kotlinx.collections.immutable.ImmutableList +private const val SMOOTH_SCROLL_JUMP_THRESHOLD = 15 + private enum class PendingAudioRecordingStartMode { None, Unlocked, @@ -73,8 +75,10 @@ internal fun ConversationScreen( onConversationDetailsClick: () -> Unit, onNavigateBack: () -> Unit, pendingDraft: ConversationDraft? = null, + pendingScrollPosition: Int? = null, pendingStartupAttachment: ConversationEntryStartupAttachment? = null, onPendingDraftConsumed: () -> Unit = {}, + onPendingScrollPositionConsumed: () -> Unit = {}, onPendingStartupAttachmentConsumed: () -> Unit = {}, screenModel: ConversationScreenModel = hiltViewModel(), ) { @@ -221,6 +225,8 @@ internal fun ConversationScreen( uiState = scaffoldUiState, isMediaPickerOpen = mediaPickerState.isOpen, messageFieldFocusRequester = messageFieldFocusRequester, + pendingScrollPosition = pendingScrollPosition, + onPendingScrollPositionConsumed = onPendingScrollPositionConsumed, onAddPeopleClick = onAddPeopleClick, onCallClick = screenModel::onCallClick, onConversationDetailsClick = onConversationDetailsClick, @@ -300,6 +306,8 @@ private fun ConversationScreenScaffold( uiState: ConversationScreenScaffoldUiState, isMediaPickerOpen: Boolean, messageFieldFocusRequester: FocusRequester, + pendingScrollPosition: Int?, + onPendingScrollPositionConsumed: () -> Unit, onAddPeopleClick: () -> Unit, onCallClick: () -> Unit, onConversationDetailsClick: () -> Unit, @@ -409,6 +417,8 @@ private fun ConversationScreenScaffold( conversationId = conversationId, uiState = uiState, contentPadding = contentPadding, + pendingScrollPosition = pendingScrollPosition, + onPendingScrollPositionConsumed = onPendingScrollPositionConsumed, onAttachmentClick = onAttachmentClick, onExternalUriClick = onExternalUriClick, onMessageClick = onMessageClick, @@ -480,6 +490,8 @@ private fun ConversationScreenContent( conversationId: String?, uiState: ConversationScreenScaffoldUiState, contentPadding: PaddingValues, + pendingScrollPosition: Int?, + onPendingScrollPositionConsumed: () -> Unit, onAttachmentClick: (contentType: String, contentUri: String) -> Unit, onExternalUriClick: (String) -> Unit, onMessageClick: (String) -> Unit, @@ -510,6 +522,14 @@ private fun ConversationScreenContent( listState = messagesListState, ) + ScrollToTargetMessage( + conversationId = conversationId, + pendingScrollPosition = pendingScrollPosition, + messages = messagesState.messages, + listState = messagesListState, + onConsumed = onPendingScrollPositionConsumed, + ) + ConversationMessages( modifier = modifier.padding(paddingValues = contentPadding), messages = messagesState.messages, @@ -628,6 +648,57 @@ private fun isScrolledToLatestMessage(listState: LazyListState): Boolean { listState.firstVisibleItemScrollOffset == 0 } +@Composable +private fun ScrollToTargetMessage( + conversationId: String?, + pendingScrollPosition: Int?, + messages: ImmutableList, + listState: LazyListState, + onConsumed: () -> Unit, +) { + LaunchedEffect( + conversationId, + pendingScrollPosition, + messages.size, + ) { + if (pendingScrollPosition == null || messages.isEmpty()) { + return@LaunchedEffect + } + + val displayIndex = messagePositionToDisplayIndex( + position = pendingScrollPosition, + size = messages.size, + ) + + val firstVisible = listState.firstVisibleItemIndex + val delta = displayIndex - firstVisible + + val intermediateIndex = when { + delta > SMOOTH_SCROLL_JUMP_THRESHOLD -> displayIndex - SMOOTH_SCROLL_JUMP_THRESHOLD + delta < -SMOOTH_SCROLL_JUMP_THRESHOLD -> displayIndex + SMOOTH_SCROLL_JUMP_THRESHOLD + else -> -1 + } + + if (intermediateIndex != -1) { + listState.scrollToItem(index = intermediateIndex.coerceIn(0, messages.size - 1)) + } + + listState.animateScrollToItem(index = displayIndex) + onConsumed() + } +} + +internal fun messagePositionToDisplayIndex(position: Int, size: Int): Int { + return when { + size <= 0 -> 0 + + else -> { + val lastIndex = size - 1 + (lastIndex - position).coerceIn(0, lastIndex) + } + } +} + @Composable private fun rememberMessagesListState( conversationId: String?, From edfdec1efa1fb5d26236bef1258883a206db98b6 Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Mon, 27 Apr 2026 17:47:39 +0300 Subject: [PATCH 62/99] Add "new message received" snackbar --- .../v2/screen/ConversationAutoScrollPolicy.kt | 5 +++ .../v2/screen/ConversationScreen.kt | 34 +++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/src/com/android/messaging/ui/conversation/v2/screen/ConversationAutoScrollPolicy.kt b/src/com/android/messaging/ui/conversation/v2/screen/ConversationAutoScrollPolicy.kt index f53e17dd..db2e4dff 100644 --- a/src/com/android/messaging/ui/conversation/v2/screen/ConversationAutoScrollPolicy.kt +++ b/src/com/android/messaging/ui/conversation/v2/screen/ConversationAutoScrollPolicy.kt @@ -10,6 +10,7 @@ internal data class ConversationAutoScrollInput( internal data class ConversationAutoScrollDecision( val shouldScrollToLatestMessage: Boolean, + val shouldShowNewMessageSnackbar: Boolean, val updatedLatestMessageId: String?, ) @@ -19,23 +20,27 @@ internal fun evaluateConversationAutoScroll( return when { input.latestMessageId == input.previousLatestMessageId -> ConversationAutoScrollDecision( shouldScrollToLatestMessage = false, + shouldShowNewMessageSnackbar = false, updatedLatestMessageId = input.latestMessageId, ) !input.hasLatestMessage -> ConversationAutoScrollDecision( shouldScrollToLatestMessage = false, + shouldShowNewMessageSnackbar = false, updatedLatestMessageId = input.latestMessageId, ) input.isLatestMessageIncoming && !input.wasScrolledToLatestMessage -> { ConversationAutoScrollDecision( shouldScrollToLatestMessage = false, + shouldShowNewMessageSnackbar = true, updatedLatestMessageId = input.latestMessageId, ) } else -> ConversationAutoScrollDecision( shouldScrollToLatestMessage = true, + shouldShowNewMessageSnackbar = false, updatedLatestMessageId = input.latestMessageId, ) } diff --git a/src/com/android/messaging/ui/conversation/v2/screen/ConversationScreen.kt b/src/com/android/messaging/ui/conversation/v2/screen/ConversationScreen.kt index 4be1b6c6..2cddd78b 100644 --- a/src/com/android/messaging/ui/conversation/v2/screen/ConversationScreen.kt +++ b/src/com/android/messaging/ui/conversation/v2/screen/ConversationScreen.kt @@ -12,6 +12,10 @@ import androidx.compose.foundation.lazy.LazyListState import androidx.compose.material3.AlertDialog import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.SnackbarResult import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable @@ -341,6 +345,9 @@ private fun ConversationScreenScaffold( onExternalUriClick: (String) -> Unit, ) { var isSimSheetVisible by rememberSaveable { mutableStateOf(value = false) } + val snackbarHostState = remember { + SnackbarHostState() + } val hasSimSelector = uiState.composer.simSelector.isAvailable LaunchedEffect(hasSimSelector) { @@ -351,6 +358,9 @@ private fun ConversationScreenScaffold( Scaffold( modifier = modifier, + snackbarHost = { + SnackbarHost(hostState = snackbarHostState) + }, topBar = { when { uiState.selection.isSelectionMode -> { @@ -416,6 +426,7 @@ private fun ConversationScreenScaffold( modifier = Modifier.fillMaxSize(), conversationId = conversationId, uiState = uiState, + snackbarHostState = snackbarHostState, contentPadding = contentPadding, pendingScrollPosition = pendingScrollPosition, onPendingScrollPositionConsumed = onPendingScrollPositionConsumed, @@ -489,6 +500,7 @@ private fun ConversationScreenContent( modifier: Modifier = Modifier, conversationId: String?, uiState: ConversationScreenScaffoldUiState, + snackbarHostState: SnackbarHostState, contentPadding: PaddingValues, pendingScrollPosition: Int?, onPendingScrollPositionConsumed: () -> Unit, @@ -520,6 +532,7 @@ private fun ConversationScreenContent( conversationId = conversationId, messages = messagesState.messages, listState = messagesListState, + snackbarHostState = snackbarHostState, ) ScrollToTargetMessage( @@ -592,9 +605,12 @@ private fun AutoScrollToLatestMessage( conversationId: String?, messages: ImmutableList, listState: LazyListState, + snackbarHostState: SnackbarHostState, ) { val latestMessage = messages.lastOrNull() val latestMessageId = latestMessage?.messageId + val newMessageText = stringResource(id = R.string.in_conversation_notify_new_message_text) + val viewActionLabel = stringResource(id = R.string.in_conversation_notify_new_message_action) var previousLatestMessageId by remember(conversationId) { mutableStateOf(value = latestMessageId) @@ -617,6 +633,9 @@ private fun AutoScrollToLatestMessage( isScrolledToLatestMessage(listState = listState) }.collect { isScrolledToLatestMessage -> wasScrolledToLatestMessage = isScrolledToLatestMessage + if (isScrolledToLatestMessage) { + snackbarHostState.currentSnackbarData?.dismiss() + } } } @@ -635,6 +654,21 @@ private fun AutoScrollToLatestMessage( ) previousLatestMessageId = autoScrollDecision.updatedLatestMessageId + + if (autoScrollDecision.shouldShowNewMessageSnackbar) { + val snackbarResult = snackbarHostState.showSnackbar( + message = newMessageText, + actionLabel = viewActionLabel, + duration = SnackbarDuration.Indefinite, + ) + + if (snackbarResult == SnackbarResult.ActionPerformed) { + listState.animateScrollToItem(index = 0) + } + + return@LaunchedEffect + } + if (!autoScrollDecision.shouldScrollToLatestMessage) { return@LaunchedEffect } From 75403239e8a70d632c30d596f580dcd0e143e80b Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Mon, 27 Apr 2026 18:16:43 +0300 Subject: [PATCH 63/99] Prevent calling on emergency numbers --- .../conversation/ConversationBindsModule.kt | 8 +++++ .../messaging/di/core/CoreProvidesModule.kt | 10 ++++++ .../usecase/IsEmergencyPhoneNumber.kt | 34 +++++++++++++++++++ .../v2/screen/ConversationViewModel.kt | 16 ++++++--- 4 files changed, 64 insertions(+), 4 deletions(-) create mode 100644 src/com/android/messaging/domain/conversation/usecase/IsEmergencyPhoneNumber.kt diff --git a/src/com/android/messaging/di/conversation/ConversationBindsModule.kt b/src/com/android/messaging/di/conversation/ConversationBindsModule.kt index 683a1c38..620fa32e 100644 --- a/src/com/android/messaging/di/conversation/ConversationBindsModule.kt +++ b/src/com/android/messaging/di/conversation/ConversationBindsModule.kt @@ -32,6 +32,8 @@ import com.android.messaging.domain.conversation.usecase.IsConversationRecipient import com.android.messaging.domain.conversation.usecase.IsConversationRecipientLimitExceededImpl import com.android.messaging.domain.conversation.usecase.IsDeviceVoiceCapable import com.android.messaging.domain.conversation.usecase.IsDeviceVoiceCapableImpl +import com.android.messaging.domain.conversation.usecase.IsEmergencyPhoneNumber +import com.android.messaging.domain.conversation.usecase.IsEmergencyPhoneNumberImpl import com.android.messaging.domain.conversation.usecase.ResolveConversationId import com.android.messaging.domain.conversation.usecase.ResolveConversationIdImpl import com.android.messaging.domain.conversation.usecase.SendConversationDraft @@ -118,6 +120,12 @@ internal abstract class ConversationBindsModule { impl: IsDeviceVoiceCapableImpl, ): IsDeviceVoiceCapable + @Binds + @Reusable + abstract fun bindIsEmergencyPhoneNumber( + impl: IsEmergencyPhoneNumberImpl, + ): IsEmergencyPhoneNumber + @Binds @Reusable abstract fun bindCreateForwardedMessage( diff --git a/src/com/android/messaging/di/core/CoreProvidesModule.kt b/src/com/android/messaging/di/core/CoreProvidesModule.kt index a3a04ec9..3bc2c482 100644 --- a/src/com/android/messaging/di/core/CoreProvidesModule.kt +++ b/src/com/android/messaging/di/core/CoreProvidesModule.kt @@ -3,6 +3,7 @@ package com.android.messaging.di.core import android.content.ClipboardManager import android.content.ContentResolver import android.content.Context +import android.telephony.TelephonyManager import dagger.Module import dagger.Provides import dagger.Reusable @@ -67,4 +68,13 @@ internal class CoreProvidesModule { ): ClipboardManager { return context.getSystemService(ClipboardManager::class.java) } + + @Provides + @Reusable + fun provideTelephonyManager( + @ApplicationContext + context: Context, + ): TelephonyManager { + return context.getSystemService(TelephonyManager::class.java) + } } diff --git a/src/com/android/messaging/domain/conversation/usecase/IsEmergencyPhoneNumber.kt b/src/com/android/messaging/domain/conversation/usecase/IsEmergencyPhoneNumber.kt new file mode 100644 index 00000000..7bb2eddf --- /dev/null +++ b/src/com/android/messaging/domain/conversation/usecase/IsEmergencyPhoneNumber.kt @@ -0,0 +1,34 @@ +package com.android.messaging.domain.conversation.usecase + +import android.telephony.PhoneNumberUtils +import android.telephony.TelephonyManager +import com.android.messaging.util.LogUtil +import javax.inject.Inject + +internal fun interface IsEmergencyPhoneNumber { + operator fun invoke(phoneNumber: String): Boolean +} + +internal class IsEmergencyPhoneNumberImpl @Inject constructor( + private val telephonyManager: TelephonyManager, +) : IsEmergencyPhoneNumber { + + @Suppress("DEPRECATION") + override operator fun invoke(phoneNumber: String): Boolean { + val normalizedPhoneNumber = PhoneNumberUtils.stripSeparators(phoneNumber) + + return try { + telephonyManager.isEmergencyNumber(normalizedPhoneNumber) + } catch (exception: IllegalStateException) { + LogUtil.w(LOG_TAG, "Unable to check emergency phone number", exception) + PhoneNumberUtils.isEmergencyNumber(normalizedPhoneNumber) + } catch (exception: UnsupportedOperationException) { + LogUtil.w(LOG_TAG, "Unable to check emergency phone number", exception) + PhoneNumberUtils.isEmergencyNumber(normalizedPhoneNumber) + } + } + + private companion object { + private const val LOG_TAG = "IsEmergencyPhoneNumber" + } +} diff --git a/src/com/android/messaging/ui/conversation/v2/screen/ConversationViewModel.kt b/src/com/android/messaging/ui/conversation/v2/screen/ConversationViewModel.kt index 63a21f0d..b21687e3 100644 --- a/src/com/android/messaging/ui/conversation/v2/screen/ConversationViewModel.kt +++ b/src/com/android/messaging/ui/conversation/v2/screen/ConversationViewModel.kt @@ -10,6 +10,7 @@ import com.android.messaging.datamodel.MessagingContentProvider import com.android.messaging.di.core.DefaultDispatcher import com.android.messaging.domain.conversation.usecase.CanAddMoreConversationParticipants import com.android.messaging.domain.conversation.usecase.IsDeviceVoiceCapable +import com.android.messaging.domain.conversation.usecase.IsEmergencyPhoneNumber import com.android.messaging.ui.conversation.v2.audio.delegate.ConversationAudioRecordingDelegate import com.android.messaging.ui.conversation.v2.composer.delegate.ConversationComposerAttachmentsDelegate import com.android.messaging.ui.conversation.v2.composer.delegate.ConversationDraftDelegate @@ -126,6 +127,7 @@ internal class ConversationViewModel @Inject constructor( private val conversationSubscriptionsRepository: ConversationSubscriptionsRepository, private val canAddMoreConversationParticipants: CanAddMoreConversationParticipants, private val isDeviceVoiceCapable: IsDeviceVoiceCapable, + private val isEmergencyPhoneNumber: IsEmergencyPhoneNumber, @param:DefaultDispatcher private val defaultDispatcher: CoroutineDispatcher, private val savedStateHandle: SavedStateHandle, @@ -342,11 +344,16 @@ internal class ConversationViewModel @Inject constructor( private fun canCall( metadataState: ConversationMetadataUiState, ): Boolean { - val isOneOnOne = metadataState is ConversationMetadataUiState.Present && - metadataState.participantCount == 1 && - metadataState.otherParticipantPhoneNumber != null + if (metadataState !is ConversationMetadataUiState.Present) { + return false + } + + val phoneNumber = metadataState.otherParticipantPhoneNumber + if (metadataState.participantCount != 1 || phoneNumber == null) { + return false + } - return isOneOnOne && isDeviceVoiceCapable() + return isDeviceVoiceCapable() && !isEmergencyPhoneNumber(phoneNumber = phoneNumber) } private fun canAddContact( @@ -448,6 +455,7 @@ internal class ConversationViewModel @Inject constructor( ConversationMetadataUiState.Present ) ?.otherParticipantPhoneNumber + ?.takeUnless(isEmergencyPhoneNumber::invoke) ?: return viewModelScope.launch(defaultDispatcher) { From 67fad39615dbbb07a2cfa71973702408e5fdde1c Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Mon, 27 Apr 2026 20:19:23 +0300 Subject: [PATCH 64/99] Add default SMS app prompt --- .../conversation/ConversationBindsModule.kt | 16 +++ .../messaging/di/core/CoreProvidesModule.kt | 10 ++ .../CheckConversationActionRequirements.kt | 35 +++++ .../ConversationActionRequirementsResult.kt | 11 ++ .../usecase/CreateDefaultSmsRoleRequest.kt | 24 ++++ .../delegate/ConversationDraftDelegate.kt | 126 +++++++++++++++--- .../ConversationMessageSelectionDelegate.kt | 60 ++++++++- .../v2/screen/ConversationScreen.kt | 9 +- .../v2/screen/ConversationScreenEffects.kt | 73 +++++++++- .../v2/screen/ConversationViewModel.kt | 74 ++++++++++ .../screen/model/ConversationScreenEffect.kt | 9 ++ 11 files changed, 422 insertions(+), 25 deletions(-) create mode 100644 src/com/android/messaging/domain/conversation/usecase/CheckConversationActionRequirements.kt create mode 100644 src/com/android/messaging/domain/conversation/usecase/ConversationActionRequirementsResult.kt create mode 100644 src/com/android/messaging/domain/conversation/usecase/CreateDefaultSmsRoleRequest.kt diff --git a/src/com/android/messaging/di/conversation/ConversationBindsModule.kt b/src/com/android/messaging/di/conversation/ConversationBindsModule.kt index 620fa32e..67a49427 100644 --- a/src/com/android/messaging/di/conversation/ConversationBindsModule.kt +++ b/src/com/android/messaging/di/conversation/ConversationBindsModule.kt @@ -24,6 +24,10 @@ import com.android.messaging.domain.contacts.usecase.IsReadContactsPermissionGra import com.android.messaging.domain.contacts.usecase.IsReadContactsPermissionGrantedImpl import com.android.messaging.domain.conversation.usecase.CanAddMoreConversationParticipants import com.android.messaging.domain.conversation.usecase.CanAddMoreConversationParticipantsImpl +import com.android.messaging.domain.conversation.usecase.CheckConversationActionRequirements +import com.android.messaging.domain.conversation.usecase.CheckConversationActionRequirementsImpl +import com.android.messaging.domain.conversation.usecase.CreateDefaultSmsRoleRequest +import com.android.messaging.domain.conversation.usecase.CreateDefaultSmsRoleRequestImpl import com.android.messaging.domain.conversation.usecase.CreateForwardedMessage import com.android.messaging.domain.conversation.usecase.CreateForwardedMessageImpl import com.android.messaging.domain.conversation.usecase.ForwardedMessageSubjectFormatter @@ -114,6 +118,18 @@ internal abstract class ConversationBindsModule { impl: CanAddMoreConversationParticipantsImpl, ): CanAddMoreConversationParticipants + @Binds + @Reusable + abstract fun bindCheckConversationActionRequirements( + impl: CheckConversationActionRequirementsImpl, + ): CheckConversationActionRequirements + + @Binds + @Reusable + abstract fun bindCreateDefaultSmsRoleRequest( + impl: CreateDefaultSmsRoleRequestImpl, + ): CreateDefaultSmsRoleRequest + @Binds @Reusable abstract fun bindIsDeviceVoiceCapable( diff --git a/src/com/android/messaging/di/core/CoreProvidesModule.kt b/src/com/android/messaging/di/core/CoreProvidesModule.kt index 3bc2c482..5badb5f3 100644 --- a/src/com/android/messaging/di/core/CoreProvidesModule.kt +++ b/src/com/android/messaging/di/core/CoreProvidesModule.kt @@ -1,5 +1,6 @@ package com.android.messaging.di.core +import android.app.role.RoleManager import android.content.ClipboardManager import android.content.ContentResolver import android.content.Context @@ -60,6 +61,15 @@ internal class CoreProvidesModule { return context.contentResolver } + @Provides + @Reusable + fun provideRoleManager( + @ApplicationContext + context: Context, + ): RoleManager { + return context.getSystemService(RoleManager::class.java) + } + @Provides @Reusable fun provideClipboardManager( diff --git a/src/com/android/messaging/domain/conversation/usecase/CheckConversationActionRequirements.kt b/src/com/android/messaging/domain/conversation/usecase/CheckConversationActionRequirements.kt new file mode 100644 index 00000000..f6e5ad3d --- /dev/null +++ b/src/com/android/messaging/domain/conversation/usecase/CheckConversationActionRequirements.kt @@ -0,0 +1,35 @@ +package com.android.messaging.domain.conversation.usecase + +import android.app.role.RoleManager +import com.android.messaging.util.PhoneUtils +import javax.inject.Inject + +internal fun interface CheckConversationActionRequirements { + operator fun invoke(): ConversationActionRequirementsResult +} + +internal class CheckConversationActionRequirementsImpl @Inject constructor( + private val roleManager: RoleManager, +) : CheckConversationActionRequirements { + + private val phoneUtils by lazy { PhoneUtils.getDefault() } + + override operator fun invoke(): ConversationActionRequirementsResult { + return when { + !phoneUtils.isSmsCapable -> ConversationActionRequirementsResult.SmsNotCapable + + !phoneUtils.hasPreferredSmsSim -> { + ConversationActionRequirementsResult.NoPreferredSmsSim + } + + !hasDefaultSmsRole() -> ConversationActionRequirementsResult.MissingDefaultSmsRole + + else -> ConversationActionRequirementsResult.Ready + } + } + + private fun hasDefaultSmsRole(): Boolean { + return roleManager.isRoleAvailable(RoleManager.ROLE_SMS) && + roleManager.isRoleHeld(RoleManager.ROLE_SMS) + } +} diff --git a/src/com/android/messaging/domain/conversation/usecase/ConversationActionRequirementsResult.kt b/src/com/android/messaging/domain/conversation/usecase/ConversationActionRequirementsResult.kt new file mode 100644 index 00000000..f744dd2c --- /dev/null +++ b/src/com/android/messaging/domain/conversation/usecase/ConversationActionRequirementsResult.kt @@ -0,0 +1,11 @@ +package com.android.messaging.domain.conversation.usecase + +internal sealed interface ConversationActionRequirementsResult { + data object Ready : ConversationActionRequirementsResult + + data object SmsNotCapable : ConversationActionRequirementsResult + + data object NoPreferredSmsSim : ConversationActionRequirementsResult + + data object MissingDefaultSmsRole : ConversationActionRequirementsResult +} diff --git a/src/com/android/messaging/domain/conversation/usecase/CreateDefaultSmsRoleRequest.kt b/src/com/android/messaging/domain/conversation/usecase/CreateDefaultSmsRoleRequest.kt new file mode 100644 index 00000000..bc3bd343 --- /dev/null +++ b/src/com/android/messaging/domain/conversation/usecase/CreateDefaultSmsRoleRequest.kt @@ -0,0 +1,24 @@ +package com.android.messaging.domain.conversation.usecase + +import android.app.role.RoleManager +import android.content.Intent +import javax.inject.Inject + +internal fun interface CreateDefaultSmsRoleRequest { + operator fun invoke(): Intent? +} + +internal class CreateDefaultSmsRoleRequestImpl @Inject constructor( + private val roleManager: RoleManager, +) : CreateDefaultSmsRoleRequest { + + override operator fun invoke(): Intent? { + return when { + roleManager.isRoleAvailable(RoleManager.ROLE_SMS) -> { + roleManager.createRequestRoleIntent(RoleManager.ROLE_SMS) + } + + else -> null + } + } +} diff --git a/src/com/android/messaging/ui/conversation/v2/composer/delegate/ConversationDraftDelegate.kt b/src/com/android/messaging/ui/conversation/v2/composer/delegate/ConversationDraftDelegate.kt index cafbee4e..e0444bb8 100644 --- a/src/com/android/messaging/ui/conversation/v2/composer/delegate/ConversationDraftDelegate.kt +++ b/src/com/android/messaging/ui/conversation/v2/composer/delegate/ConversationDraftDelegate.kt @@ -1,14 +1,19 @@ package com.android.messaging.ui.conversation.v2.composer.delegate +import android.app.Activity +import com.android.messaging.R import com.android.messaging.data.conversation.model.draft.ConversationDraft import com.android.messaging.data.conversation.model.draft.ConversationDraftAttachment import com.android.messaging.data.conversation.model.draft.ConversationDraftPendingAttachment import com.android.messaging.data.conversation.repository.ConversationDraftsRepository import com.android.messaging.di.core.ApplicationCoroutineScope import com.android.messaging.di.core.DefaultDispatcher +import com.android.messaging.domain.conversation.usecase.CheckConversationActionRequirements +import com.android.messaging.domain.conversation.usecase.ConversationActionRequirementsResult import com.android.messaging.domain.conversation.usecase.SendConversationDraft import com.android.messaging.ui.conversation.v2.common.ConversationScreenDelegate import com.android.messaging.ui.conversation.v2.composer.model.ConversationDraftState +import com.android.messaging.ui.conversation.v2.screen.model.ConversationScreenEffect import com.android.messaging.util.LogUtil import com.android.messaging.util.core.extension.unitFlow import javax.inject.Inject @@ -18,8 +23,10 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.collect @@ -39,6 +46,8 @@ import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext internal interface ConversationDraftDelegate : ConversationScreenDelegate { + val effects: Flow + fun onMessageTextChanged(messageText: String) fun onSelfParticipantIdChanged(selfParticipantId: String) @@ -68,6 +77,8 @@ internal interface ConversationDraftDelegate : ConversationScreenDelegate( + extraBufferCapacity = 1, + ) private val _state = MutableStateFlow(ConversationDraftState()) + override val effects = _effects.asSharedFlow() override val state = _state.asStateFlow() private val draftEditorState = MutableStateFlow(DraftEditorState()) @@ -91,6 +107,7 @@ internal class ConversationDraftDelegateImpl @Inject constructor( private var boundScope: CoroutineScope? = null private var pendingDraftSeed: PendingDraftSeed? = null + private var pendingDefaultSmsRoleSendRequest: DraftSendRequest? = null override fun bind( scope: CoroutineScope, @@ -185,11 +202,22 @@ internal class ConversationDraftDelegateImpl @Inject constructor( } override fun onSendClick() { - val scope = boundScope ?: return - val sendRequest = markSendingAndCreateSendRequestOrNull() ?: return + createSendRequestOrNull() + ?.let(::sendDraftWhenActionRequirementsSatisfied) + } - launchDraftOperation(scope = scope) { - createSendDraftFlow(sendRequest) + override fun onDefaultSmsRoleRequestResult(resultCode: Int): Boolean { + val sendRequest = pendingDefaultSmsRoleSendRequest ?: return false + + pendingDefaultSmsRoleSendRequest = null + + return when (resultCode) { + Activity.RESULT_OK -> { + sendDraftWhenActionRequirementsSatisfied(sendRequest = sendRequest) + true + } + + else -> false } } @@ -320,6 +348,55 @@ internal class ConversationDraftDelegateImpl @Inject constructor( } } + private fun sendDraftWhenActionRequirementsSatisfied(sendRequest: DraftSendRequest) { + when (checkConversationActionRequirements()) { + ConversationActionRequirementsResult.Ready -> { + sendDraft(sendRequest = sendRequest) + } + + ConversationActionRequirementsResult.SmsNotCapable -> { + emitEffect( + effect = ConversationScreenEffect.ShowMessage( + messageResId = R.string.sms_disabled, + ), + ) + } + + ConversationActionRequirementsResult.NoPreferredSmsSim -> { + emitEffect( + effect = ConversationScreenEffect.ShowMessage( + messageResId = R.string.no_preferred_sim_selected, + ), + ) + } + + ConversationActionRequirementsResult.MissingDefaultSmsRole -> { + pendingDefaultSmsRoleSendRequest = sendRequest + emitEffect( + effect = ConversationScreenEffect.RequestDefaultSmsRole( + isSending = true, + ), + ) + } + } + } + + private fun sendDraft(sendRequest: DraftSendRequest) { + val scope = boundScope ?: return + + if (markSendingForSendRequest(sendRequest = sendRequest)) { + launchDraftOperation(scope = scope) { + createSendDraftFlow(sendRequest) + } + } + } + + private fun emitEffect(effect: ConversationScreenEffect) { + boundScope?.launch(defaultDispatcher) { + _effects.emit(effect) + } + } + private fun createSendDraftFlow(sendRequest: DraftSendRequest): Flow { var didClearDraftAfterSend = false @@ -479,27 +556,40 @@ internal class ConversationDraftDelegateImpl @Inject constructor( } } - private fun markSendingAndCreateSendRequestOrNull(): DraftSendRequest? { - var sendRequest: DraftSendRequest? = null + private fun createSendRequestOrNull(): DraftSendRequest? { + val currentDraftEditorState = draftEditorState.value + val conversationId = currentDraftEditorState.conversationId - updateDraftEditorState { currentDraftEditorState -> - if (!currentDraftEditorState.canSendDraft()) { - return@updateDraftEditorState currentDraftEditorState + return when { + !currentDraftEditorState.canSendDraft() -> null + conversationId == null -> null + + else -> { + DraftSendRequest( + conversationId = conversationId, + draft = currentDraftEditorState.effectiveDraft, + ) } + } + } - val conversationId = currentDraftEditorState - .conversationId - ?: return@updateDraftEditorState currentDraftEditorState + private fun markSendingForSendRequest(sendRequest: DraftSendRequest): Boolean { + var didMarkSending = false - sendRequest = DraftSendRequest( - conversationId = conversationId, - draft = currentDraftEditorState.effectiveDraft, - ) + updateDraftEditorState { state -> + val isSameConversation = state.conversationId == sendRequest.conversationId + + val canMarkSending = isSameConversation && !state.isSending + + if (!canMarkSending) { + return@updateDraftEditorState state + } - currentDraftEditorState.markSending() + didMarkSending = true + state.markSending() } - return sendRequest + return didMarkSending } private fun runDraftOperationBoundary( diff --git a/src/com/android/messaging/ui/conversation/v2/messages/delegate/ConversationMessageSelectionDelegate.kt b/src/com/android/messaging/ui/conversation/v2/messages/delegate/ConversationMessageSelectionDelegate.kt index 6e407ee1..2a1dc183 100644 --- a/src/com/android/messaging/ui/conversation/v2/messages/delegate/ConversationMessageSelectionDelegate.kt +++ b/src/com/android/messaging/ui/conversation/v2/messages/delegate/ConversationMessageSelectionDelegate.kt @@ -1,9 +1,13 @@ package com.android.messaging.ui.conversation.v2.messages.delegate +import android.app.Activity import android.content.ClipData import android.content.ClipboardManager +import com.android.messaging.R import com.android.messaging.data.conversation.repository.ConversationsRepository import com.android.messaging.di.core.DefaultDispatcher +import com.android.messaging.domain.conversation.usecase.CheckConversationActionRequirements +import com.android.messaging.domain.conversation.usecase.ConversationActionRequirementsResult import com.android.messaging.domain.conversation.usecase.CreateForwardedMessage import com.android.messaging.ui.conversation.v2.common.ConversationScreenDelegate import com.android.messaging.ui.conversation.v2.mediapicker.repository.ConversationAttachmentRepository @@ -45,9 +49,12 @@ internal interface ConversationMessageSelectionDelegate : fun dismissMessageSelection() fun confirmDeleteSelectedMessages() + + fun onDefaultSmsRoleRequestResult(resultCode: Int): Boolean } internal class ConversationMessageSelectionDelegateImpl @Inject constructor( + private val checkConversationActionRequirements: CheckConversationActionRequirements, private val clipboardManager: ClipboardManager, private val conversationAttachmentRepository: ConversationAttachmentRepository, private val conversationMessagesDelegate: ConversationMessagesDelegate, @@ -69,6 +76,7 @@ internal class ConversationMessageSelectionDelegateImpl @Inject constructor( override val state = _state.asStateFlow() private var boundScope: CoroutineScope? = null + private var pendingDefaultSmsRoleResendMessageId: String? = null override fun bind( scope: CoroutineScope, @@ -154,6 +162,18 @@ internal class ConversationMessageSelectionDelegateImpl @Inject constructor( ) } + override fun onDefaultSmsRoleRequestResult(resultCode: Int): Boolean { + val messageId = pendingDefaultSmsRoleResendMessageId ?: return false + pendingDefaultSmsRoleResendMessageId = null + + if (resultCode != Activity.RESULT_OK) { + return true + } + + resendMessageWhenActionRequirementsSatisfied(messageId = messageId) + return true + } + private fun bindSelectionUiState(scope: CoroutineScope) { scope.launch(defaultDispatcher) { combine( @@ -272,9 +292,43 @@ internal class ConversationMessageSelectionDelegateImpl @Inject constructor( clearMessageSelection() - conversationsRepository.resendMessage( - messageId = selectedMessage.messageId, - ) + resendMessageWhenActionRequirementsSatisfied(messageId = selectedMessage.messageId) + } + + private fun resendMessageWhenActionRequirementsSatisfied(messageId: String) { + when (checkConversationActionRequirements()) { + ConversationActionRequirementsResult.Ready -> { + conversationsRepository.resendMessage( + messageId = messageId, + ) + } + + ConversationActionRequirementsResult.SmsNotCapable -> { + emitEffect( + effect = ConversationScreenEffect.ShowMessage( + messageResId = R.string.sms_disabled, + ), + ) + } + + ConversationActionRequirementsResult.NoPreferredSmsSim -> { + emitEffect( + effect = ConversationScreenEffect.ShowMessage( + messageResId = R.string.no_preferred_sim_selected, + ), + ) + } + + ConversationActionRequirementsResult.MissingDefaultSmsRole -> { + pendingDefaultSmsRoleResendMessageId = messageId + + emitEffect( + effect = ConversationScreenEffect.RequestDefaultSmsRole( + isSending = true, + ), + ) + } + } } private fun singleSelectedMessageOrNull(): ConversationMessageUiModel? { diff --git a/src/com/android/messaging/ui/conversation/v2/screen/ConversationScreen.kt b/src/com/android/messaging/ui/conversation/v2/screen/ConversationScreen.kt index 2cddd78b..704a3aba 100644 --- a/src/com/android/messaging/ui/conversation/v2/screen/ConversationScreen.kt +++ b/src/com/android/messaging/ui/conversation/v2/screen/ConversationScreen.kt @@ -101,6 +101,9 @@ internal fun ConversationScreen( val hostBoundsState = remember { mutableStateOf(value = null) } + val snackbarHostState = remember { + SnackbarHostState() + } var pendingAudioRecordingStartMode by rememberSaveable { mutableStateOf(value = PendingAudioRecordingStartMode.None) @@ -211,6 +214,7 @@ internal fun ConversationScreen( ConversationScreenEffects( screenModel = screenModel, + snackbarHostState = snackbarHostState, hostBoundsState = hostBoundsState, onNavigateBack = onNavigateBack, ) @@ -227,6 +231,7 @@ internal fun ConversationScreen( .fillMaxSize(), conversationId = conversationId, uiState = scaffoldUiState, + snackbarHostState = snackbarHostState, isMediaPickerOpen = mediaPickerState.isOpen, messageFieldFocusRequester = messageFieldFocusRequester, pendingScrollPosition = pendingScrollPosition, @@ -308,6 +313,7 @@ private fun ConversationScreenScaffold( modifier: Modifier = Modifier, conversationId: String?, uiState: ConversationScreenScaffoldUiState, + snackbarHostState: SnackbarHostState, isMediaPickerOpen: Boolean, messageFieldFocusRequester: FocusRequester, pendingScrollPosition: Int?, @@ -345,9 +351,6 @@ private fun ConversationScreenScaffold( onExternalUriClick: (String) -> Unit, ) { var isSimSheetVisible by rememberSaveable { mutableStateOf(value = false) } - val snackbarHostState = remember { - SnackbarHostState() - } val hasSimSelector = uiState.composer.simSelector.isAvailable LaunchedEffect(hasSimSelector) { diff --git a/src/com/android/messaging/ui/conversation/v2/screen/ConversationScreenEffects.kt b/src/com/android/messaging/ui/conversation/v2/screen/ConversationScreenEffects.kt index 2d226311..65c5bc7c 100644 --- a/src/com/android/messaging/ui/conversation/v2/screen/ConversationScreenEffects.kt +++ b/src/com/android/messaging/ui/conversation/v2/screen/ConversationScreenEffects.kt @@ -1,11 +1,17 @@ package com.android.messaging.ui.conversation.v2.screen +import android.content.ActivityNotFoundException import android.content.ContentResolver import android.content.Context import android.content.Intent import android.graphics.Point import android.graphics.Rect import android.net.Uri +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.SnackbarResult import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.State @@ -18,6 +24,7 @@ import com.android.messaging.ui.UIIntents import com.android.messaging.ui.conversation.MessageDetailsDialog import com.android.messaging.ui.conversation.v2.screen.model.ConversationScreenEffect import com.android.messaging.util.ContentType +import com.android.messaging.util.LogUtil import com.android.messaging.util.UiUtils import com.android.messaging.util.UriUtil import kotlin.math.roundToInt @@ -26,21 +33,38 @@ import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.first import kotlinx.coroutines.withContext +private const val LOG_TAG = "ConversationScreenEffects" + @Composable internal fun ConversationScreenEffects( screenModel: ConversationScreenModel, + snackbarHostState: SnackbarHostState, hostBoundsState: State, onNavigateBack: () -> Unit, ) { val context = LocalContext.current + val defaultSmsRoleLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.StartActivityForResult(), + ) { result -> + screenModel.onDefaultSmsRoleRequestResult(resultCode = result.resultCode) + } - LaunchedEffect(screenModel, context, hostBoundsState, onNavigateBack) { + LaunchedEffect(screenModel, context, snackbarHostState, hostBoundsState, onNavigateBack) { screenModel.effects.collect { effect -> when (effect) { ConversationScreenEffect.CloseConversation -> { onNavigateBack() } + is ConversationScreenEffect.RequestDefaultSmsRole -> { + requestDefaultSmsRole( + context = context, + snackbarHostState = snackbarHostState, + effect = effect, + onActionClick = screenModel::onDefaultSmsRolePromptActionClick, + ) + } + is ConversationScreenEffect.LaunchAddContactFlow -> { UIIntents.get().launchAddContactActivity( context, @@ -84,6 +108,16 @@ internal fun ConversationScreenEffects( ) } + is ConversationScreenEffect.LaunchDefaultSmsRoleRequest -> { + launchDefaultSmsRoleRequest( + effect = effect, + launchRoleRequest = { intent -> + defaultSmsRoleLauncher.launch(intent) + }, + onLaunchFailed = screenModel::onDefaultSmsRoleRequestLaunchFailed, + ) + } + is ConversationScreenEffect.LaunchForwardMessage -> { UIIntents.get().launchForwardMessageActivity( context, @@ -117,6 +151,43 @@ internal fun ConversationScreenEffects( } } +private suspend fun requestDefaultSmsRole( + context: Context, + snackbarHostState: SnackbarHostState, + effect: ConversationScreenEffect.RequestDefaultSmsRole, + onActionClick: () -> Unit, +) { + snackbarHostState.currentSnackbarData?.dismiss() + + val messageResId = when { + effect.isSending -> R.string.requires_default_sms_app_to_send + else -> R.string.requires_default_sms_app + } + + val snackbarResult = snackbarHostState.showSnackbar( + message = context.getString(messageResId), + actionLabel = context.getString(R.string.requires_default_sms_change_button), + duration = SnackbarDuration.Indefinite, + ) + + if (snackbarResult == SnackbarResult.ActionPerformed) { + onActionClick() + } +} + +private fun launchDefaultSmsRoleRequest( + effect: ConversationScreenEffect.LaunchDefaultSmsRoleRequest, + launchRoleRequest: (Intent) -> Unit, + onLaunchFailed: () -> Unit, +) { + try { + launchRoleRequest(effect.intent) + } catch (exception: ActivityNotFoundException) { + LogUtil.w(LOG_TAG, "Couldn't find activity", exception) + onLaunchFailed() + } +} + private fun openExternalUri( context: Context, uri: String, diff --git a/src/com/android/messaging/ui/conversation/v2/screen/ConversationViewModel.kt b/src/com/android/messaging/ui/conversation/v2/screen/ConversationViewModel.kt index b21687e3..b413dc96 100644 --- a/src/com/android/messaging/ui/conversation/v2/screen/ConversationViewModel.kt +++ b/src/com/android/messaging/ui/conversation/v2/screen/ConversationViewModel.kt @@ -1,14 +1,17 @@ package com.android.messaging.ui.conversation.v2.screen +import android.app.Activity import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.android.messaging.R import com.android.messaging.data.conversation.model.draft.ConversationDraft import com.android.messaging.data.conversation.repository.ConversationSubscriptionsRepository import com.android.messaging.data.media.model.ConversationMediaItem import com.android.messaging.datamodel.MessagingContentProvider import com.android.messaging.di.core.DefaultDispatcher import com.android.messaging.domain.conversation.usecase.CanAddMoreConversationParticipants +import com.android.messaging.domain.conversation.usecase.CreateDefaultSmsRoleRequest import com.android.messaging.domain.conversation.usecase.IsDeviceVoiceCapable import com.android.messaging.domain.conversation.usecase.IsEmergencyPhoneNumber import com.android.messaging.ui.conversation.v2.audio.delegate.ConversationAudioRecordingDelegate @@ -100,6 +103,9 @@ internal interface ConversationScreenModel { fun dismissMessageSelection() fun confirmDeleteSelectedMessages() fun onSendClick() + fun onDefaultSmsRolePromptActionClick() + fun onDefaultSmsRoleRequestResult(resultCode: Int) + fun onDefaultSmsRoleRequestLaunchFailed() fun persistDraft() fun onArchiveConversationClick() @@ -126,6 +132,7 @@ internal class ConversationViewModel @Inject constructor( private val conversationComposerUiStateMapper: ConversationComposerUiStateMapper, private val conversationSubscriptionsRepository: ConversationSubscriptionsRepository, private val canAddMoreConversationParticipants: CanAddMoreConversationParticipants, + private val createDefaultSmsRoleRequest: CreateDefaultSmsRoleRequest, private val isDeviceVoiceCapable: IsDeviceVoiceCapable, private val isEmergencyPhoneNumber: IsEmergencyPhoneNumber, @param:DefaultDispatcher @@ -306,6 +313,9 @@ internal class ConversationViewModel @Inject constructor( } private fun bindDelegateEffects() { + viewModelScope.launch(defaultDispatcher) { + conversationDraftDelegate.effects.collect(_effects::emit) + } viewModelScope.launch(defaultDispatcher) { conversationMediaPickerDelegate.effects.collect(_effects::emit) } @@ -579,6 +589,70 @@ internal class ConversationViewModel @Inject constructor( conversationDraftDelegate.onSendClick() } + override fun onDefaultSmsRolePromptActionClick() { + viewModelScope.launch(defaultDispatcher) { + when (val requestIntent = createDefaultSmsRoleRequest()) { + null -> { + _effects.emit( + ConversationScreenEffect.ShowMessage( + messageResId = R.string.activity_not_found_message, + ), + ) + } + + else -> { + _effects.emit( + ConversationScreenEffect.LaunchDefaultSmsRoleRequest( + intent = requestIntent, + ), + ) + } + } + } + } + + override fun onDefaultSmsRoleRequestResult(resultCode: Int) { + if (handlePendingDefaultSmsRoleRequestResult(resultCode = resultCode)) { + return + } + + if (resultCode != Activity.RESULT_OK) { + return + } + + viewModelScope.launch(defaultDispatcher) { + _effects.emit( + ConversationScreenEffect.ShowMessage( + messageResId = R.string.toast_after_setting_default_sms_app, + ), + ) + } + } + + private fun handlePendingDefaultSmsRoleRequestResult(resultCode: Int): Boolean { + val didHandleDraftSend = conversationDraftDelegate.onDefaultSmsRoleRequestResult( + resultCode = resultCode, + ) + + if (didHandleDraftSend) { + return true + } + + return conversationMessageSelectionDelegate.onDefaultSmsRoleRequestResult( + resultCode = resultCode, + ) + } + + override fun onDefaultSmsRoleRequestLaunchFailed() { + viewModelScope.launch(defaultDispatcher) { + _effects.emit( + ConversationScreenEffect.ShowMessage( + messageResId = R.string.activity_not_found_message, + ), + ) + } + } + override fun persistDraft() { conversationDraftDelegate.persistDraft() } diff --git a/src/com/android/messaging/ui/conversation/v2/screen/model/ConversationScreenEffect.kt b/src/com/android/messaging/ui/conversation/v2/screen/model/ConversationScreenEffect.kt index 19591558..6202d507 100644 --- a/src/com/android/messaging/ui/conversation/v2/screen/model/ConversationScreenEffect.kt +++ b/src/com/android/messaging/ui/conversation/v2/screen/model/ConversationScreenEffect.kt @@ -1,5 +1,6 @@ package com.android.messaging.ui.conversation.v2.screen.model +import android.content.Intent import com.android.messaging.datamodel.data.ConversationMessageData import com.android.messaging.datamodel.data.ConversationParticipantsData import com.android.messaging.datamodel.data.MessageData @@ -8,10 +9,18 @@ import com.android.messaging.datamodel.data.ParticipantData internal sealed interface ConversationScreenEffect { data object CloseConversation : ConversationScreenEffect + data class RequestDefaultSmsRole( + val isSending: Boolean, + ) : ConversationScreenEffect + data class LaunchAddContactFlow( val destination: String, ) : ConversationScreenEffect + data class LaunchDefaultSmsRoleRequest( + val intent: Intent, + ) : ConversationScreenEffect + data class LaunchForwardMessage( val message: MessageData, ) : ConversationScreenEffect From 9d604c3c82e757fdeb6b5f3b1020a43aa04fcaec Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Tue, 28 Apr 2026 00:59:53 +0300 Subject: [PATCH 65/99] Organize conversation data and use case packages --- .../message/ConversationMessageDetailsData.kt | 11 +++++ .../recipient}/ConversationRecipientsPage.kt | 3 +- .../repository/ConversationDraftStore.kt | 12 ++--- .../ConversationDraftsRepository.kt | 22 +++++---- .../ConversationMetadataNotifier.kt | 16 ------- .../ConversationRecipientsRepository.kt | 1 + .../repository/ConversationsRepository.kt | 7 +-- .../conversation/ConversationBindsModule.kt | 48 ++++++++----------- .../CheckConversationActionRequirements.kt | 2 +- .../ConversationActionRequirementsResult.kt | 2 +- .../CreateDefaultSmsRoleRequest.kt | 2 +- .../{ => draft}/SendConversationDraft.kt | 2 +- .../{ => forward}/CreateForwardedMessage.kt | 2 +- .../ForwardedMessageSubjectFormatter.kt | 2 +- .../CanAddMoreConversationParticipants.kt | 2 +- .../IsConversationRecipientLimitExceeded.kt | 2 +- .../ResolveConversationId.kt | 4 +- .../model/ResolveConversationIdResult.kt | 2 +- .../{ => telephony}/IsDeviceVoiceCapable.kt | 2 +- .../{ => telephony}/IsEmergencyPhoneNumber.kt | 2 +- .../AddParticipantsViewModel.kt | 6 +-- .../delegate/ConversationDraftDelegate.kt | 6 +-- .../v2/entry/ConversationEntryViewModel.kt | 6 +-- .../ConversationMessageSelectionDelegate.kt | 6 +-- .../delegate/RecipientPickerDelegate.kt | 2 +- .../v2/screen/ConversationViewModel.kt | 8 ++-- 26 files changed, 79 insertions(+), 101 deletions(-) create mode 100644 src/com/android/messaging/data/conversation/model/message/ConversationMessageDetailsData.kt rename src/com/android/messaging/data/conversation/{repository => model/recipient}/ConversationRecipientsPage.kt (56%) delete mode 100644 src/com/android/messaging/data/conversation/repository/ConversationMetadataNotifier.kt rename src/com/android/messaging/domain/conversation/usecase/{ => action}/CheckConversationActionRequirements.kt (94%) rename src/com/android/messaging/domain/conversation/usecase/{ => action}/ConversationActionRequirementsResult.kt (84%) rename src/com/android/messaging/domain/conversation/usecase/{ => action}/CreateDefaultSmsRoleRequest.kt (90%) rename src/com/android/messaging/domain/conversation/usecase/{ => draft}/SendConversationDraft.kt (97%) rename src/com/android/messaging/domain/conversation/usecase/{ => forward}/CreateForwardedMessage.kt (96%) rename src/com/android/messaging/domain/conversation/usecase/{ => forward}/ForwardedMessageSubjectFormatter.kt (92%) rename src/com/android/messaging/domain/conversation/usecase/{ => participant}/CanAddMoreConversationParticipants.kt (88%) rename src/com/android/messaging/domain/conversation/usecase/{ => participant}/IsConversationRecipientLimitExceeded.kt (87%) rename src/com/android/messaging/domain/conversation/usecase/{ => participant}/ResolveConversationId.kt (94%) rename src/com/android/messaging/domain/conversation/usecase/{ => participant}/model/ResolveConversationIdResult.kt (78%) rename src/com/android/messaging/domain/conversation/usecase/{ => telephony}/IsDeviceVoiceCapable.kt (83%) rename src/com/android/messaging/domain/conversation/usecase/{ => telephony}/IsEmergencyPhoneNumber.kt (94%) diff --git a/src/com/android/messaging/data/conversation/model/message/ConversationMessageDetailsData.kt b/src/com/android/messaging/data/conversation/model/message/ConversationMessageDetailsData.kt new file mode 100644 index 00000000..26ac8164 --- /dev/null +++ b/src/com/android/messaging/data/conversation/model/message/ConversationMessageDetailsData.kt @@ -0,0 +1,11 @@ +package com.android.messaging.data.conversation.model.message + +import com.android.messaging.datamodel.data.ConversationMessageData +import com.android.messaging.datamodel.data.ConversationParticipantsData +import com.android.messaging.datamodel.data.ParticipantData + +internal data class ConversationMessageDetailsData( + val message: ConversationMessageData, + val participants: ConversationParticipantsData, + val selfParticipant: ParticipantData?, +) diff --git a/src/com/android/messaging/data/conversation/repository/ConversationRecipientsPage.kt b/src/com/android/messaging/data/conversation/model/recipient/ConversationRecipientsPage.kt similarity index 56% rename from src/com/android/messaging/data/conversation/repository/ConversationRecipientsPage.kt rename to src/com/android/messaging/data/conversation/model/recipient/ConversationRecipientsPage.kt index 0bd1ff48..7a84291b 100644 --- a/src/com/android/messaging/data/conversation/repository/ConversationRecipientsPage.kt +++ b/src/com/android/messaging/data/conversation/model/recipient/ConversationRecipientsPage.kt @@ -1,6 +1,5 @@ -package com.android.messaging.data.conversation.repository +package com.android.messaging.data.conversation.model.recipient -import com.android.messaging.data.conversation.model.recipient.ConversationRecipient import kotlinx.collections.immutable.ImmutableList internal data class ConversationRecipientsPage( diff --git a/src/com/android/messaging/data/conversation/repository/ConversationDraftStore.kt b/src/com/android/messaging/data/conversation/repository/ConversationDraftStore.kt index 3bdd4313..a2501bb2 100644 --- a/src/com/android/messaging/data/conversation/repository/ConversationDraftStore.kt +++ b/src/com/android/messaging/data/conversation/repository/ConversationDraftStore.kt @@ -6,12 +6,8 @@ import com.android.messaging.datamodel.data.ConversationListItemData import com.android.messaging.datamodel.data.MessageData import javax.inject.Inject -internal data class ConversationDraftConversation( - val selfParticipantId: String, -) - internal interface ConversationDraftStore { - fun getConversation(conversationId: String): ConversationDraftConversation? + fun getSelfParticipantId(conversationId: String): String? fun readDraftMessage( conversationId: String, @@ -26,15 +22,13 @@ internal interface ConversationDraftStore { internal class ConversationDraftStoreImpl @Inject constructor() : ConversationDraftStore { - override fun getConversation(conversationId: String): ConversationDraftConversation? { + override fun getSelfParticipantId(conversationId: String): String? { val conversation = ConversationListItemData.getExistingConversation( DataModel.get().database, conversationId, ) ?: return null - return ConversationDraftConversation( - selfParticipantId = conversation.selfId.orEmpty(), - ) + return conversation.selfId.orEmpty() } override fun readDraftMessage( diff --git a/src/com/android/messaging/data/conversation/repository/ConversationDraftsRepository.kt b/src/com/android/messaging/data/conversation/repository/ConversationDraftsRepository.kt index 568d5ab1..fc0e18bb 100644 --- a/src/com/android/messaging/data/conversation/repository/ConversationDraftsRepository.kt +++ b/src/com/android/messaging/data/conversation/repository/ConversationDraftsRepository.kt @@ -40,7 +40,6 @@ internal class ConversationDraftsRepositoryImpl @Inject constructor( private val conversationDraftMessageDataMapper: ConversationDraftMessageDataMapper, private val conversationMessageDataDraftMapper: ConversationMessageDataDraftMapper, private val conversationDraftStore: ConversationDraftStore, - private val conversationMetadataNotifier: ConversationMetadataNotifier, @param:IoDispatcher private val ioDispatcher: CoroutineDispatcher, ) : ConversationDraftsRepository { @@ -82,7 +81,7 @@ internal class ConversationDraftsRepositoryImpl @Inject constructor( message = boundMessage, ) - conversationMetadataNotifier.notifyConversationMetadataChanged( + notifyConversationMetadataChanged( conversationId = conversationId, ) } @@ -105,30 +104,34 @@ internal class ConversationDraftsRepositoryImpl @Inject constructor( } } + private fun notifyConversationMetadataChanged(conversationId: String) { + MessagingContentProvider.notifyConversationMetadataChanged(conversationId) + } + private fun loadConversationDraft(conversationId: String): ConversationDraft { - val conversation = conversationDraftStore.getConversation( + val selfParticipantId = conversationDraftStore.getSelfParticipantId( conversationId = conversationId, ) ?: return ConversationDraft() val draftMessage = conversationDraftStore.readDraftMessage( conversationId = conversationId, - selfParticipantId = conversation.selfParticipantId, + selfParticipantId = selfParticipantId, ) return createConversationDraft( - conversation = conversation, + selfParticipantId = selfParticipantId, draftMessage = draftMessage, ) } private fun createConversationDraft( - conversation: ConversationDraftConversation, + selfParticipantId: String, draftMessage: MessageData?, ): ConversationDraft { return when (draftMessage) { null -> { ConversationDraft( - selfParticipantId = conversation.selfParticipantId, + selfParticipantId = selfParticipantId, ) } @@ -136,7 +139,7 @@ internal class ConversationDraftsRepositoryImpl @Inject constructor( resolveDraftAttachmentMetadata( draft = conversationMessageDataDraftMapper.map( messageData = draftMessage, - fallbackSelfParticipantId = conversation.selfParticipantId, + fallbackSelfParticipantId = selfParticipantId, ), ) } @@ -215,7 +218,7 @@ internal class ConversationDraftsRepositoryImpl @Inject constructor( return message } - val conversation = conversationDraftStore.getConversation( + val selfParticipantId = conversationDraftStore.getSelfParticipantId( conversationId = conversationId, ) ?: run { LogUtil.w( @@ -225,7 +228,6 @@ internal class ConversationDraftsRepositoryImpl @Inject constructor( return null } - val selfParticipantId = conversation.selfParticipantId if (message.selfId == null) { message.bindSelfId(selfParticipantId) } diff --git a/src/com/android/messaging/data/conversation/repository/ConversationMetadataNotifier.kt b/src/com/android/messaging/data/conversation/repository/ConversationMetadataNotifier.kt deleted file mode 100644 index 9c1dd658..00000000 --- a/src/com/android/messaging/data/conversation/repository/ConversationMetadataNotifier.kt +++ /dev/null @@ -1,16 +0,0 @@ -package com.android.messaging.data.conversation.repository - -import com.android.messaging.datamodel.MessagingContentProvider -import javax.inject.Inject - -internal interface ConversationMetadataNotifier { - fun notifyConversationMetadataChanged(conversationId: String) -} - -internal class ConversationMetadataNotifierImpl @Inject constructor() : - ConversationMetadataNotifier { - - override fun notifyConversationMetadataChanged(conversationId: String) { - MessagingContentProvider.notifyConversationMetadataChanged(conversationId) - } -} diff --git a/src/com/android/messaging/data/conversation/repository/ConversationRecipientsRepository.kt b/src/com/android/messaging/data/conversation/repository/ConversationRecipientsRepository.kt index b3a86e88..591a0269 100644 --- a/src/com/android/messaging/data/conversation/repository/ConversationRecipientsRepository.kt +++ b/src/com/android/messaging/data/conversation/repository/ConversationRecipientsRepository.kt @@ -9,6 +9,7 @@ import android.provider.ContactsContract.CommonDataKinds.Email import android.provider.ContactsContract.CommonDataKinds.Phone import android.provider.ContactsContract.Directory import com.android.messaging.data.conversation.model.recipient.ConversationRecipient +import com.android.messaging.data.conversation.model.recipient.ConversationRecipientsPage import com.android.messaging.di.core.IoDispatcher import com.android.messaging.util.core.extension.typedFlow import javax.inject.Inject diff --git a/src/com/android/messaging/data/conversation/repository/ConversationsRepository.kt b/src/com/android/messaging/data/conversation/repository/ConversationsRepository.kt index abc51e36..2d8456fc 100644 --- a/src/com/android/messaging/data/conversation/repository/ConversationsRepository.kt +++ b/src/com/android/messaging/data/conversation/repository/ConversationsRepository.kt @@ -3,6 +3,7 @@ package com.android.messaging.data.conversation.repository import android.content.ContentResolver import android.database.ContentObserver import android.net.Uri +import com.android.messaging.data.conversation.model.message.ConversationMessageDetailsData import com.android.messaging.data.conversation.model.metadata.ConversationComposerAvailability import com.android.messaging.data.conversation.model.metadata.ConversationMetadata import com.android.messaging.datamodel.DatabaseHelper.ConversationColumns @@ -56,12 +57,6 @@ internal interface ConversationsRepository { fun deleteConversation(conversationId: String) } -internal data class ConversationMessageDetailsData( - val message: ConversationMessageData, - val participants: ConversationParticipantsData, - val selfParticipant: ParticipantData?, -) - internal class ConversationsRepositoryImpl @Inject constructor( private val contentResolver: ContentResolver, @param:IoDispatcher diff --git a/src/com/android/messaging/di/conversation/ConversationBindsModule.kt b/src/com/android/messaging/di/conversation/ConversationBindsModule.kt index 67a49427..74eee20a 100644 --- a/src/com/android/messaging/di/conversation/ConversationBindsModule.kt +++ b/src/com/android/messaging/di/conversation/ConversationBindsModule.kt @@ -8,8 +8,6 @@ import com.android.messaging.data.conversation.repository.ConversationDraftStore import com.android.messaging.data.conversation.repository.ConversationDraftStoreImpl import com.android.messaging.data.conversation.repository.ConversationDraftsRepository import com.android.messaging.data.conversation.repository.ConversationDraftsRepositoryImpl -import com.android.messaging.data.conversation.repository.ConversationMetadataNotifier -import com.android.messaging.data.conversation.repository.ConversationMetadataNotifierImpl import com.android.messaging.data.conversation.repository.ConversationParticipantsRepository import com.android.messaging.data.conversation.repository.ConversationParticipantsRepositoryImpl import com.android.messaging.data.conversation.repository.ConversationRecipientsRepository @@ -22,26 +20,26 @@ import com.android.messaging.data.media.repository.ConversationMediaRepository import com.android.messaging.data.media.repository.ConversationMediaRepositoryImpl import com.android.messaging.domain.contacts.usecase.IsReadContactsPermissionGranted import com.android.messaging.domain.contacts.usecase.IsReadContactsPermissionGrantedImpl -import com.android.messaging.domain.conversation.usecase.CanAddMoreConversationParticipants -import com.android.messaging.domain.conversation.usecase.CanAddMoreConversationParticipantsImpl -import com.android.messaging.domain.conversation.usecase.CheckConversationActionRequirements -import com.android.messaging.domain.conversation.usecase.CheckConversationActionRequirementsImpl -import com.android.messaging.domain.conversation.usecase.CreateDefaultSmsRoleRequest -import com.android.messaging.domain.conversation.usecase.CreateDefaultSmsRoleRequestImpl -import com.android.messaging.domain.conversation.usecase.CreateForwardedMessage -import com.android.messaging.domain.conversation.usecase.CreateForwardedMessageImpl -import com.android.messaging.domain.conversation.usecase.ForwardedMessageSubjectFormatter -import com.android.messaging.domain.conversation.usecase.ForwardedMessageSubjectFormatterImpl -import com.android.messaging.domain.conversation.usecase.IsConversationRecipientLimitExceeded -import com.android.messaging.domain.conversation.usecase.IsConversationRecipientLimitExceededImpl -import com.android.messaging.domain.conversation.usecase.IsDeviceVoiceCapable -import com.android.messaging.domain.conversation.usecase.IsDeviceVoiceCapableImpl -import com.android.messaging.domain.conversation.usecase.IsEmergencyPhoneNumber -import com.android.messaging.domain.conversation.usecase.IsEmergencyPhoneNumberImpl -import com.android.messaging.domain.conversation.usecase.ResolveConversationId -import com.android.messaging.domain.conversation.usecase.ResolveConversationIdImpl -import com.android.messaging.domain.conversation.usecase.SendConversationDraft -import com.android.messaging.domain.conversation.usecase.SendConversationDraftImpl +import com.android.messaging.domain.conversation.usecase.action.CheckConversationActionRequirements +import com.android.messaging.domain.conversation.usecase.action.CheckConversationActionRequirementsImpl +import com.android.messaging.domain.conversation.usecase.action.CreateDefaultSmsRoleRequest +import com.android.messaging.domain.conversation.usecase.action.CreateDefaultSmsRoleRequestImpl +import com.android.messaging.domain.conversation.usecase.draft.SendConversationDraft +import com.android.messaging.domain.conversation.usecase.draft.SendConversationDraftImpl +import com.android.messaging.domain.conversation.usecase.forward.CreateForwardedMessage +import com.android.messaging.domain.conversation.usecase.forward.CreateForwardedMessageImpl +import com.android.messaging.domain.conversation.usecase.forward.ForwardedMessageSubjectFormatter +import com.android.messaging.domain.conversation.usecase.forward.ForwardedMessageSubjectFormatterImpl +import com.android.messaging.domain.conversation.usecase.participant.CanAddMoreConversationParticipants +import com.android.messaging.domain.conversation.usecase.participant.CanAddMoreConversationParticipantsImpl +import com.android.messaging.domain.conversation.usecase.participant.IsConversationRecipientLimitExceeded +import com.android.messaging.domain.conversation.usecase.participant.IsConversationRecipientLimitExceededImpl +import com.android.messaging.domain.conversation.usecase.participant.ResolveConversationId +import com.android.messaging.domain.conversation.usecase.participant.ResolveConversationIdImpl +import com.android.messaging.domain.conversation.usecase.telephony.IsDeviceVoiceCapable +import com.android.messaging.domain.conversation.usecase.telephony.IsDeviceVoiceCapableImpl +import com.android.messaging.domain.conversation.usecase.telephony.IsEmergencyPhoneNumber +import com.android.messaging.domain.conversation.usecase.telephony.IsEmergencyPhoneNumberImpl import com.android.messaging.ui.conversation.v2.composer.mapper.ConversationComposerAttachmentUiModelMapper import com.android.messaging.ui.conversation.v2.composer.mapper.ConversationComposerAttachmentUiModelMapperImpl import com.android.messaging.ui.conversation.v2.composer.mapper.ConversationComposerUiStateMapper @@ -88,12 +86,6 @@ internal abstract class ConversationBindsModule { impl: ConversationDraftStoreImpl, ): ConversationDraftStore - @Binds - @Reusable - abstract fun bindConversationMetadataNotifier( - impl: ConversationMetadataNotifierImpl, - ): ConversationMetadataNotifier - @Binds @Reusable abstract fun bindConversationDraftsRepository( diff --git a/src/com/android/messaging/domain/conversation/usecase/CheckConversationActionRequirements.kt b/src/com/android/messaging/domain/conversation/usecase/action/CheckConversationActionRequirements.kt similarity index 94% rename from src/com/android/messaging/domain/conversation/usecase/CheckConversationActionRequirements.kt rename to src/com/android/messaging/domain/conversation/usecase/action/CheckConversationActionRequirements.kt index f6e5ad3d..65d1b8e2 100644 --- a/src/com/android/messaging/domain/conversation/usecase/CheckConversationActionRequirements.kt +++ b/src/com/android/messaging/domain/conversation/usecase/action/CheckConversationActionRequirements.kt @@ -1,4 +1,4 @@ -package com.android.messaging.domain.conversation.usecase +package com.android.messaging.domain.conversation.usecase.action import android.app.role.RoleManager import com.android.messaging.util.PhoneUtils diff --git a/src/com/android/messaging/domain/conversation/usecase/ConversationActionRequirementsResult.kt b/src/com/android/messaging/domain/conversation/usecase/action/ConversationActionRequirementsResult.kt similarity index 84% rename from src/com/android/messaging/domain/conversation/usecase/ConversationActionRequirementsResult.kt rename to src/com/android/messaging/domain/conversation/usecase/action/ConversationActionRequirementsResult.kt index f744dd2c..d56421e9 100644 --- a/src/com/android/messaging/domain/conversation/usecase/ConversationActionRequirementsResult.kt +++ b/src/com/android/messaging/domain/conversation/usecase/action/ConversationActionRequirementsResult.kt @@ -1,4 +1,4 @@ -package com.android.messaging.domain.conversation.usecase +package com.android.messaging.domain.conversation.usecase.action internal sealed interface ConversationActionRequirementsResult { data object Ready : ConversationActionRequirementsResult diff --git a/src/com/android/messaging/domain/conversation/usecase/CreateDefaultSmsRoleRequest.kt b/src/com/android/messaging/domain/conversation/usecase/action/CreateDefaultSmsRoleRequest.kt similarity index 90% rename from src/com/android/messaging/domain/conversation/usecase/CreateDefaultSmsRoleRequest.kt rename to src/com/android/messaging/domain/conversation/usecase/action/CreateDefaultSmsRoleRequest.kt index bc3bd343..bac862af 100644 --- a/src/com/android/messaging/domain/conversation/usecase/CreateDefaultSmsRoleRequest.kt +++ b/src/com/android/messaging/domain/conversation/usecase/action/CreateDefaultSmsRoleRequest.kt @@ -1,4 +1,4 @@ -package com.android.messaging.domain.conversation.usecase +package com.android.messaging.domain.conversation.usecase.action import android.app.role.RoleManager import android.content.Intent diff --git a/src/com/android/messaging/domain/conversation/usecase/SendConversationDraft.kt b/src/com/android/messaging/domain/conversation/usecase/draft/SendConversationDraft.kt similarity index 97% rename from src/com/android/messaging/domain/conversation/usecase/SendConversationDraft.kt rename to src/com/android/messaging/domain/conversation/usecase/draft/SendConversationDraft.kt index 2057627c..9c9ebd1a 100644 --- a/src/com/android/messaging/domain/conversation/usecase/SendConversationDraft.kt +++ b/src/com/android/messaging/domain/conversation/usecase/draft/SendConversationDraft.kt @@ -1,4 +1,4 @@ -package com.android.messaging.domain.conversation.usecase +package com.android.messaging.domain.conversation.usecase.draft import com.android.messaging.data.conversation.mapper.ConversationDraftMessageDataMapper import com.android.messaging.data.conversation.model.draft.ConversationDraft diff --git a/src/com/android/messaging/domain/conversation/usecase/CreateForwardedMessage.kt b/src/com/android/messaging/domain/conversation/usecase/forward/CreateForwardedMessage.kt similarity index 96% rename from src/com/android/messaging/domain/conversation/usecase/CreateForwardedMessage.kt rename to src/com/android/messaging/domain/conversation/usecase/forward/CreateForwardedMessage.kt index e7931e5e..cb75b65c 100644 --- a/src/com/android/messaging/domain/conversation/usecase/CreateForwardedMessage.kt +++ b/src/com/android/messaging/domain/conversation/usecase/forward/CreateForwardedMessage.kt @@ -1,4 +1,4 @@ -package com.android.messaging.domain.conversation.usecase +package com.android.messaging.domain.conversation.usecase.forward import com.android.messaging.data.conversation.repository.ConversationsRepository import com.android.messaging.datamodel.data.MessageData diff --git a/src/com/android/messaging/domain/conversation/usecase/ForwardedMessageSubjectFormatter.kt b/src/com/android/messaging/domain/conversation/usecase/forward/ForwardedMessageSubjectFormatter.kt similarity index 92% rename from src/com/android/messaging/domain/conversation/usecase/ForwardedMessageSubjectFormatter.kt rename to src/com/android/messaging/domain/conversation/usecase/forward/ForwardedMessageSubjectFormatter.kt index b5d4d90d..b2af1489 100644 --- a/src/com/android/messaging/domain/conversation/usecase/ForwardedMessageSubjectFormatter.kt +++ b/src/com/android/messaging/domain/conversation/usecase/forward/ForwardedMessageSubjectFormatter.kt @@ -1,4 +1,4 @@ -package com.android.messaging.domain.conversation.usecase +package com.android.messaging.domain.conversation.usecase.forward import android.content.Context import com.android.messaging.R diff --git a/src/com/android/messaging/domain/conversation/usecase/CanAddMoreConversationParticipants.kt b/src/com/android/messaging/domain/conversation/usecase/participant/CanAddMoreConversationParticipants.kt similarity index 88% rename from src/com/android/messaging/domain/conversation/usecase/CanAddMoreConversationParticipants.kt rename to src/com/android/messaging/domain/conversation/usecase/participant/CanAddMoreConversationParticipants.kt index 8e0b4f87..ebe0bbc2 100644 --- a/src/com/android/messaging/domain/conversation/usecase/CanAddMoreConversationParticipants.kt +++ b/src/com/android/messaging/domain/conversation/usecase/participant/CanAddMoreConversationParticipants.kt @@ -1,4 +1,4 @@ -package com.android.messaging.domain.conversation.usecase +package com.android.messaging.domain.conversation.usecase.participant import com.android.messaging.datamodel.data.ContactPickerData import javax.inject.Inject diff --git a/src/com/android/messaging/domain/conversation/usecase/IsConversationRecipientLimitExceeded.kt b/src/com/android/messaging/domain/conversation/usecase/participant/IsConversationRecipientLimitExceeded.kt similarity index 87% rename from src/com/android/messaging/domain/conversation/usecase/IsConversationRecipientLimitExceeded.kt rename to src/com/android/messaging/domain/conversation/usecase/participant/IsConversationRecipientLimitExceeded.kt index ee433387..2101c9cc 100644 --- a/src/com/android/messaging/domain/conversation/usecase/IsConversationRecipientLimitExceeded.kt +++ b/src/com/android/messaging/domain/conversation/usecase/participant/IsConversationRecipientLimitExceeded.kt @@ -1,4 +1,4 @@ -package com.android.messaging.domain.conversation.usecase +package com.android.messaging.domain.conversation.usecase.participant import com.android.messaging.datamodel.data.ContactPickerData import javax.inject.Inject diff --git a/src/com/android/messaging/domain/conversation/usecase/ResolveConversationId.kt b/src/com/android/messaging/domain/conversation/usecase/participant/ResolveConversationId.kt similarity index 94% rename from src/com/android/messaging/domain/conversation/usecase/ResolveConversationId.kt rename to src/com/android/messaging/domain/conversation/usecase/participant/ResolveConversationId.kt index 84041454..f9dcdb7a 100644 --- a/src/com/android/messaging/domain/conversation/usecase/ResolveConversationId.kt +++ b/src/com/android/messaging/domain/conversation/usecase/participant/ResolveConversationId.kt @@ -1,10 +1,10 @@ -package com.android.messaging.domain.conversation.usecase +package com.android.messaging.domain.conversation.usecase.participant import com.android.messaging.datamodel.action.ActionMonitor import com.android.messaging.datamodel.action.GetOrCreateConversationAction import com.android.messaging.datamodel.data.ParticipantData import com.android.messaging.di.core.MainDispatcher -import com.android.messaging.domain.conversation.usecase.model.ResolveConversationIdResult +import com.android.messaging.domain.conversation.usecase.participant.model.ResolveConversationIdResult import javax.inject.Inject import kotlin.coroutines.resume import kotlinx.coroutines.CoroutineDispatcher diff --git a/src/com/android/messaging/domain/conversation/usecase/model/ResolveConversationIdResult.kt b/src/com/android/messaging/domain/conversation/usecase/participant/model/ResolveConversationIdResult.kt similarity index 78% rename from src/com/android/messaging/domain/conversation/usecase/model/ResolveConversationIdResult.kt rename to src/com/android/messaging/domain/conversation/usecase/participant/model/ResolveConversationIdResult.kt index b8bf8a75..d89df860 100644 --- a/src/com/android/messaging/domain/conversation/usecase/model/ResolveConversationIdResult.kt +++ b/src/com/android/messaging/domain/conversation/usecase/participant/model/ResolveConversationIdResult.kt @@ -1,4 +1,4 @@ -package com.android.messaging.domain.conversation.usecase.model +package com.android.messaging.domain.conversation.usecase.participant.model internal sealed interface ResolveConversationIdResult { data object EmptyDestinations : ResolveConversationIdResult diff --git a/src/com/android/messaging/domain/conversation/usecase/IsDeviceVoiceCapable.kt b/src/com/android/messaging/domain/conversation/usecase/telephony/IsDeviceVoiceCapable.kt similarity index 83% rename from src/com/android/messaging/domain/conversation/usecase/IsDeviceVoiceCapable.kt rename to src/com/android/messaging/domain/conversation/usecase/telephony/IsDeviceVoiceCapable.kt index 4e224f34..e3ac8b4d 100644 --- a/src/com/android/messaging/domain/conversation/usecase/IsDeviceVoiceCapable.kt +++ b/src/com/android/messaging/domain/conversation/usecase/telephony/IsDeviceVoiceCapable.kt @@ -1,4 +1,4 @@ -package com.android.messaging.domain.conversation.usecase +package com.android.messaging.domain.conversation.usecase.telephony import com.android.messaging.util.PhoneUtils import javax.inject.Inject diff --git a/src/com/android/messaging/domain/conversation/usecase/IsEmergencyPhoneNumber.kt b/src/com/android/messaging/domain/conversation/usecase/telephony/IsEmergencyPhoneNumber.kt similarity index 94% rename from src/com/android/messaging/domain/conversation/usecase/IsEmergencyPhoneNumber.kt rename to src/com/android/messaging/domain/conversation/usecase/telephony/IsEmergencyPhoneNumber.kt index 7bb2eddf..35ce1d46 100644 --- a/src/com/android/messaging/domain/conversation/usecase/IsEmergencyPhoneNumber.kt +++ b/src/com/android/messaging/domain/conversation/usecase/telephony/IsEmergencyPhoneNumber.kt @@ -1,4 +1,4 @@ -package com.android.messaging.domain.conversation.usecase +package com.android.messaging.domain.conversation.usecase.telephony import android.telephony.PhoneNumberUtils import android.telephony.TelephonyManager diff --git a/src/com/android/messaging/ui/conversation/v2/addparticipants/AddParticipantsViewModel.kt b/src/com/android/messaging/ui/conversation/v2/addparticipants/AddParticipantsViewModel.kt index fabdcba3..8b04a35e 100644 --- a/src/com/android/messaging/ui/conversation/v2/addparticipants/AddParticipantsViewModel.kt +++ b/src/com/android/messaging/ui/conversation/v2/addparticipants/AddParticipantsViewModel.kt @@ -7,9 +7,9 @@ import com.android.messaging.R import com.android.messaging.data.conversation.model.recipient.ConversationRecipient import com.android.messaging.data.conversation.repository.ConversationParticipantsRepository import com.android.messaging.di.core.MainDispatcher -import com.android.messaging.domain.conversation.usecase.IsConversationRecipientLimitExceeded -import com.android.messaging.domain.conversation.usecase.ResolveConversationId -import com.android.messaging.domain.conversation.usecase.model.ResolveConversationIdResult +import com.android.messaging.domain.conversation.usecase.participant.IsConversationRecipientLimitExceeded +import com.android.messaging.domain.conversation.usecase.participant.ResolveConversationId +import com.android.messaging.domain.conversation.usecase.participant.model.ResolveConversationIdResult import com.android.messaging.ui.conversation.v2.addparticipants.model.AddParticipantsEffect import com.android.messaging.ui.conversation.v2.addparticipants.model.AddParticipantsUiState import com.android.messaging.ui.conversation.v2.recipientpicker.delegate.RecipientPickerDelegate diff --git a/src/com/android/messaging/ui/conversation/v2/composer/delegate/ConversationDraftDelegate.kt b/src/com/android/messaging/ui/conversation/v2/composer/delegate/ConversationDraftDelegate.kt index e0444bb8..e215badd 100644 --- a/src/com/android/messaging/ui/conversation/v2/composer/delegate/ConversationDraftDelegate.kt +++ b/src/com/android/messaging/ui/conversation/v2/composer/delegate/ConversationDraftDelegate.kt @@ -8,9 +8,9 @@ import com.android.messaging.data.conversation.model.draft.ConversationDraftPend import com.android.messaging.data.conversation.repository.ConversationDraftsRepository import com.android.messaging.di.core.ApplicationCoroutineScope import com.android.messaging.di.core.DefaultDispatcher -import com.android.messaging.domain.conversation.usecase.CheckConversationActionRequirements -import com.android.messaging.domain.conversation.usecase.ConversationActionRequirementsResult -import com.android.messaging.domain.conversation.usecase.SendConversationDraft +import com.android.messaging.domain.conversation.usecase.action.CheckConversationActionRequirements +import com.android.messaging.domain.conversation.usecase.action.ConversationActionRequirementsResult +import com.android.messaging.domain.conversation.usecase.draft.SendConversationDraft import com.android.messaging.ui.conversation.v2.common.ConversationScreenDelegate import com.android.messaging.ui.conversation.v2.composer.model.ConversationDraftState import com.android.messaging.ui.conversation.v2.screen.model.ConversationScreenEffect diff --git a/src/com/android/messaging/ui/conversation/v2/entry/ConversationEntryViewModel.kt b/src/com/android/messaging/ui/conversation/v2/entry/ConversationEntryViewModel.kt index 5b4f975c..e37387f0 100644 --- a/src/com/android/messaging/ui/conversation/v2/entry/ConversationEntryViewModel.kt +++ b/src/com/android/messaging/ui/conversation/v2/entry/ConversationEntryViewModel.kt @@ -7,9 +7,9 @@ import com.android.messaging.R import com.android.messaging.data.conversation.mapper.ConversationMessageDataDraftMapper import com.android.messaging.datamodel.data.MessageData import com.android.messaging.di.core.MainDispatcher -import com.android.messaging.domain.conversation.usecase.IsConversationRecipientLimitExceeded -import com.android.messaging.domain.conversation.usecase.ResolveConversationId -import com.android.messaging.domain.conversation.usecase.model.ResolveConversationIdResult +import com.android.messaging.domain.conversation.usecase.participant.IsConversationRecipientLimitExceeded +import com.android.messaging.domain.conversation.usecase.participant.ResolveConversationId +import com.android.messaging.domain.conversation.usecase.participant.model.ResolveConversationIdResult import com.android.messaging.ui.conversation.v2.entry.model.ConversationEntryEffect import com.android.messaging.ui.conversation.v2.entry.model.ConversationEntryLaunchRequest import com.android.messaging.ui.conversation.v2.entry.model.ConversationEntryStartupAttachment diff --git a/src/com/android/messaging/ui/conversation/v2/messages/delegate/ConversationMessageSelectionDelegate.kt b/src/com/android/messaging/ui/conversation/v2/messages/delegate/ConversationMessageSelectionDelegate.kt index 2a1dc183..1b9ce052 100644 --- a/src/com/android/messaging/ui/conversation/v2/messages/delegate/ConversationMessageSelectionDelegate.kt +++ b/src/com/android/messaging/ui/conversation/v2/messages/delegate/ConversationMessageSelectionDelegate.kt @@ -6,9 +6,9 @@ import android.content.ClipboardManager import com.android.messaging.R import com.android.messaging.data.conversation.repository.ConversationsRepository import com.android.messaging.di.core.DefaultDispatcher -import com.android.messaging.domain.conversation.usecase.CheckConversationActionRequirements -import com.android.messaging.domain.conversation.usecase.ConversationActionRequirementsResult -import com.android.messaging.domain.conversation.usecase.CreateForwardedMessage +import com.android.messaging.domain.conversation.usecase.action.CheckConversationActionRequirements +import com.android.messaging.domain.conversation.usecase.action.ConversationActionRequirementsResult +import com.android.messaging.domain.conversation.usecase.forward.CreateForwardedMessage import com.android.messaging.ui.conversation.v2.common.ConversationScreenDelegate import com.android.messaging.ui.conversation.v2.mediapicker.repository.ConversationAttachmentRepository import com.android.messaging.ui.conversation.v2.messages.model.message.ConversationMessagePartUiModel diff --git a/src/com/android/messaging/ui/conversation/v2/recipientpicker/delegate/RecipientPickerDelegate.kt b/src/com/android/messaging/ui/conversation/v2/recipientpicker/delegate/RecipientPickerDelegate.kt index b7f5bb43..091305a9 100644 --- a/src/com/android/messaging/ui/conversation/v2/recipientpicker/delegate/RecipientPickerDelegate.kt +++ b/src/com/android/messaging/ui/conversation/v2/recipientpicker/delegate/RecipientPickerDelegate.kt @@ -2,7 +2,7 @@ package com.android.messaging.ui.conversation.v2.recipientpicker.delegate import androidx.lifecycle.SavedStateHandle import com.android.messaging.data.conversation.model.recipient.ConversationRecipient -import com.android.messaging.data.conversation.repository.ConversationRecipientsPage +import com.android.messaging.data.conversation.model.recipient.ConversationRecipientsPage import com.android.messaging.data.conversation.repository.ConversationRecipientsRepository import com.android.messaging.di.core.DefaultDispatcher import com.android.messaging.domain.contacts.usecase.IsReadContactsPermissionGranted diff --git a/src/com/android/messaging/ui/conversation/v2/screen/ConversationViewModel.kt b/src/com/android/messaging/ui/conversation/v2/screen/ConversationViewModel.kt index b413dc96..44ef285f 100644 --- a/src/com/android/messaging/ui/conversation/v2/screen/ConversationViewModel.kt +++ b/src/com/android/messaging/ui/conversation/v2/screen/ConversationViewModel.kt @@ -10,10 +10,10 @@ import com.android.messaging.data.conversation.repository.ConversationSubscripti import com.android.messaging.data.media.model.ConversationMediaItem import com.android.messaging.datamodel.MessagingContentProvider import com.android.messaging.di.core.DefaultDispatcher -import com.android.messaging.domain.conversation.usecase.CanAddMoreConversationParticipants -import com.android.messaging.domain.conversation.usecase.CreateDefaultSmsRoleRequest -import com.android.messaging.domain.conversation.usecase.IsDeviceVoiceCapable -import com.android.messaging.domain.conversation.usecase.IsEmergencyPhoneNumber +import com.android.messaging.domain.conversation.usecase.action.CreateDefaultSmsRoleRequest +import com.android.messaging.domain.conversation.usecase.participant.CanAddMoreConversationParticipants +import com.android.messaging.domain.conversation.usecase.telephony.IsDeviceVoiceCapable +import com.android.messaging.domain.conversation.usecase.telephony.IsEmergencyPhoneNumber import com.android.messaging.ui.conversation.v2.audio.delegate.ConversationAudioRecordingDelegate import com.android.messaging.ui.conversation.v2.composer.delegate.ConversationComposerAttachmentsDelegate import com.android.messaging.ui.conversation.v2.composer.delegate.ConversationDraftDelegate From 4623cee97dbfd09ed0a34ecb77e3734c6d270e1c Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Tue, 28 Apr 2026 01:01:24 +0300 Subject: [PATCH 66/99] Add conversation draft send protocol use case --- .../model/metadata/ConversationMetadata.kt | 1 + .../model/send/ConversationSendData.kt | 11 +++ .../repository/ConversationsRepository.kt | 3 + .../conversation/ConversationBindsModule.kt | 8 ++ .../draft/GetConversationDraftSendProtocol.kt | 78 +++++++++++++++++++ .../model/ConversationDraftSendProtocol.kt | 6 ++ 6 files changed, 107 insertions(+) create mode 100644 src/com/android/messaging/data/conversation/model/send/ConversationSendData.kt create mode 100644 src/com/android/messaging/domain/conversation/usecase/draft/GetConversationDraftSendProtocol.kt create mode 100644 src/com/android/messaging/domain/conversation/usecase/draft/model/ConversationDraftSendProtocol.kt diff --git a/src/com/android/messaging/data/conversation/model/metadata/ConversationMetadata.kt b/src/com/android/messaging/data/conversation/model/metadata/ConversationMetadata.kt index 8a7bba93..71f3f9b1 100644 --- a/src/com/android/messaging/data/conversation/model/metadata/ConversationMetadata.kt +++ b/src/com/android/messaging/data/conversation/model/metadata/ConversationMetadata.kt @@ -4,6 +4,7 @@ internal data class ConversationMetadata( val conversationName: String, val selfParticipantId: String, val isGroupConversation: Boolean, + val includeEmailAddress: Boolean, val participantCount: Int, val otherParticipantDisplayDestination: String?, val otherParticipantNormalizedDestination: String?, diff --git a/src/com/android/messaging/data/conversation/model/send/ConversationSendData.kt b/src/com/android/messaging/data/conversation/model/send/ConversationSendData.kt new file mode 100644 index 00000000..f0f84e3d --- /dev/null +++ b/src/com/android/messaging/data/conversation/model/send/ConversationSendData.kt @@ -0,0 +1,11 @@ +package com.android.messaging.data.conversation.model.send + +import com.android.messaging.data.conversation.model.metadata.ConversationMetadata +import com.android.messaging.datamodel.data.ConversationParticipantsData +import com.android.messaging.datamodel.data.ParticipantData + +internal data class ConversationSendData( + val metadata: ConversationMetadata, + val participants: ConversationParticipantsData, + val selfParticipant: ParticipantData?, +) diff --git a/src/com/android/messaging/data/conversation/repository/ConversationsRepository.kt b/src/com/android/messaging/data/conversation/repository/ConversationsRepository.kt index 2d8456fc..951c1f1d 100644 --- a/src/com/android/messaging/data/conversation/repository/ConversationsRepository.kt +++ b/src/com/android/messaging/data/conversation/repository/ConversationsRepository.kt @@ -232,6 +232,9 @@ internal class ConversationsRepositoryImpl @Inject constructor( ConversationColumns.CURRENT_SELF_ID, ), isGroupConversation = participantCount > 1, + includeEmailAddress = cursor.getInt( + ConversationColumns.INCLUDE_EMAIL_ADDRESS, + ) == 1, participantCount = participantCount, otherParticipantDisplayDestination = otherParticipant ?.displayDestination diff --git a/src/com/android/messaging/di/conversation/ConversationBindsModule.kt b/src/com/android/messaging/di/conversation/ConversationBindsModule.kt index 74eee20a..28efcaaa 100644 --- a/src/com/android/messaging/di/conversation/ConversationBindsModule.kt +++ b/src/com/android/messaging/di/conversation/ConversationBindsModule.kt @@ -24,6 +24,8 @@ import com.android.messaging.domain.conversation.usecase.action.CheckConversatio import com.android.messaging.domain.conversation.usecase.action.CheckConversationActionRequirementsImpl import com.android.messaging.domain.conversation.usecase.action.CreateDefaultSmsRoleRequest import com.android.messaging.domain.conversation.usecase.action.CreateDefaultSmsRoleRequestImpl +import com.android.messaging.domain.conversation.usecase.draft.GetConversationDraftSendProtocol +import com.android.messaging.domain.conversation.usecase.draft.GetConversationDraftSendProtocolImpl import com.android.messaging.domain.conversation.usecase.draft.SendConversationDraft import com.android.messaging.domain.conversation.usecase.draft.SendConversationDraftImpl import com.android.messaging.domain.conversation.usecase.forward.CreateForwardedMessage @@ -140,6 +142,12 @@ internal abstract class ConversationBindsModule { impl: CreateForwardedMessageImpl, ): CreateForwardedMessage + @Binds + @Reusable + abstract fun bindGetConversationDraftSendProtocol( + impl: GetConversationDraftSendProtocolImpl, + ): GetConversationDraftSendProtocol + @Binds @Reusable abstract fun bindIsReadContactsPermissionGranted( diff --git a/src/com/android/messaging/domain/conversation/usecase/draft/GetConversationDraftSendProtocol.kt b/src/com/android/messaging/domain/conversation/usecase/draft/GetConversationDraftSendProtocol.kt new file mode 100644 index 00000000..1b536ca0 --- /dev/null +++ b/src/com/android/messaging/domain/conversation/usecase/draft/GetConversationDraftSendProtocol.kt @@ -0,0 +1,78 @@ +package com.android.messaging.domain.conversation.usecase.draft + +import com.android.messaging.data.conversation.model.draft.ConversationDraft +import com.android.messaging.data.conversation.model.send.ConversationSendData +import com.android.messaging.datamodel.MessageTextStats +import com.android.messaging.datamodel.data.ParticipantData +import com.android.messaging.domain.conversation.usecase.draft.model.ConversationDraftSendProtocol +import com.android.messaging.sms.MmsSmsUtils +import com.android.messaging.sms.MmsUtils +import javax.inject.Inject + +internal fun interface GetConversationDraftSendProtocol { + operator fun invoke( + draft: ConversationDraft, + sendData: ConversationSendData, + ): ConversationDraftSendProtocol +} + +internal class GetConversationDraftSendProtocolImpl @Inject constructor() : + GetConversationDraftSendProtocol { + + override operator fun invoke( + draft: ConversationDraft, + sendData: ConversationSendData, + ): ConversationDraftSendProtocol { + return when { + shouldSendAsMms( + draft = draft, + sendData = sendData, + ) -> ConversationDraftSendProtocol.MMS + + else -> ConversationDraftSendProtocol.SMS + } + } + + private fun shouldSendAsMms( + draft: ConversationDraft, + sendData: ConversationSendData, + ): Boolean { + val selfSubId = resolveSelfSubId(sendData = sendData) + val conversationMetadata = sendData.metadata + + val groupConversationRequiresMms = conversationMetadata.isGroupConversation && + MmsUtils.groupMmsEnabled(selfSubId) + + val emailAddressRequiresMms = MmsSmsUtils.getRequireMmsForEmailAddress( + conversationMetadata.includeEmailAddress, + selfSubId, + ) + + return when { + draft.attachments.isNotEmpty() -> true + draft.subjectText.isNotBlank() -> true + groupConversationRequiresMms -> true + emailAddressRequiresMms -> true + + else -> messageLengthRequiresMms( + messageText = draft.messageText, + selfSubId = selfSubId, + ) + } + } + + private fun resolveSelfSubId(sendData: ConversationSendData): Int { + return sendData.selfParticipant?.subId ?: ParticipantData.DEFAULT_SELF_SUB_ID + } + + private fun messageLengthRequiresMms( + messageText: String, + selfSubId: Int, + ): Boolean { + return MessageTextStats() + .apply { + updateMessageTextStats(selfSubId, messageText) + } + .messageLengthRequiresMms + } +} diff --git a/src/com/android/messaging/domain/conversation/usecase/draft/model/ConversationDraftSendProtocol.kt b/src/com/android/messaging/domain/conversation/usecase/draft/model/ConversationDraftSendProtocol.kt new file mode 100644 index 00000000..c07dfa8f --- /dev/null +++ b/src/com/android/messaging/domain/conversation/usecase/draft/model/ConversationDraftSendProtocol.kt @@ -0,0 +1,6 @@ +package com.android.messaging.domain.conversation.usecase.draft.model + +internal enum class ConversationDraftSendProtocol { + SMS, + MMS, +} From 872bf62595c66ef740c77064d1399b075f7c737c Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Tue, 28 Apr 2026 01:02:16 +0300 Subject: [PATCH 67/99] Harden conversation draft sending --- .../ConversationDraftMessageDataMapper.kt | 4 +- .../repository/ConversationsRepository.kt | 27 ++ .../usecase/draft/SendConversationDraft.kt | 250 ++++++++++++++---- .../SendConversationDraftException.kt | 63 +++++ 4 files changed, 298 insertions(+), 46 deletions(-) create mode 100644 src/com/android/messaging/domain/conversation/usecase/draft/exception/SendConversationDraftException.kt diff --git a/src/com/android/messaging/data/conversation/mapper/ConversationDraftMessageDataMapper.kt b/src/com/android/messaging/data/conversation/mapper/ConversationDraftMessageDataMapper.kt index 8e452a55..d2a66f7e 100644 --- a/src/com/android/messaging/data/conversation/mapper/ConversationDraftMessageDataMapper.kt +++ b/src/com/android/messaging/data/conversation/mapper/ConversationDraftMessageDataMapper.kt @@ -12,6 +12,7 @@ internal interface ConversationDraftMessageDataMapper { fun map( conversationId: String, draft: ConversationDraft, + forceMms: Boolean = false, ): MessageData } @@ -21,10 +22,11 @@ internal class ConversationDraftMessageDataMapperImpl @Inject constructor() : override fun map( conversationId: String, draft: ConversationDraft, + forceMms: Boolean, ): MessageData { val selfParticipantId = draft.selfParticipantId.takeIf { it.isNotBlank() } val messageParts = draft.attachments.mapNotNull(::createMessagePartDataOrNull) - val isMms = draft.subjectText.isNotBlank() || messageParts.isNotEmpty() + val isMms = forceMms || draft.subjectText.isNotBlank() || messageParts.isNotEmpty() val message = when { isMms -> MessageData.createDraftMmsMessage( diff --git a/src/com/android/messaging/data/conversation/repository/ConversationsRepository.kt b/src/com/android/messaging/data/conversation/repository/ConversationsRepository.kt index 951c1f1d..838a40ed 100644 --- a/src/com/android/messaging/data/conversation/repository/ConversationsRepository.kt +++ b/src/com/android/messaging/data/conversation/repository/ConversationsRepository.kt @@ -6,6 +6,7 @@ import android.net.Uri import com.android.messaging.data.conversation.model.message.ConversationMessageDetailsData 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.send.ConversationSendData import com.android.messaging.datamodel.DatabaseHelper.ConversationColumns import com.android.messaging.datamodel.DatabaseHelper.ParticipantColumns import com.android.messaging.datamodel.MessagingContentProvider @@ -34,6 +35,11 @@ import kotlinx.coroutines.flow.map internal interface ConversationsRepository { fun getConversationMetadata(conversationId: String): Flow fun getConversationMessages(conversationId: String): Flow> + fun getConversationSendData( + conversationId: String, + requestedSelfParticipantId: String, + ): ConversationSendData? + fun getConversationMessage( conversationId: String, messageId: String, @@ -86,6 +92,27 @@ internal class ConversationsRepositoryImpl @Inject constructor( .flowOn(ioDispatcher) } + override fun getConversationSendData( + conversationId: String, + requestedSelfParticipantId: String, + ): ConversationSendData? { + if (conversationId.isBlank()) { + return null + } + + val uri = MessagingContentProvider.buildConversationMetadataUri(conversationId) + val metadata = queryConversationMetadata(uri = uri) ?: return null + val resolvedSelfParticipantId = requestedSelfParticipantId + .takeIf { it.isNotBlank() } + ?: metadata.selfParticipantId + + return ConversationSendData( + metadata = metadata, + participants = queryConversationParticipants(conversationId = conversationId), + selfParticipant = queryParticipant(participantId = resolvedSelfParticipantId), + ) + } + override fun getConversationMessage( conversationId: String, messageId: String, diff --git a/src/com/android/messaging/domain/conversation/usecase/draft/SendConversationDraft.kt b/src/com/android/messaging/domain/conversation/usecase/draft/SendConversationDraft.kt index 9c9ebd1a..39db0bf9 100644 --- a/src/com/android/messaging/domain/conversation/usecase/draft/SendConversationDraft.kt +++ b/src/com/android/messaging/domain/conversation/usecase/draft/SendConversationDraft.kt @@ -2,14 +2,32 @@ package com.android.messaging.domain.conversation.usecase.draft import com.android.messaging.data.conversation.mapper.ConversationDraftMessageDataMapper import com.android.messaging.data.conversation.model.draft.ConversationDraft +import com.android.messaging.data.conversation.model.draft.ConversationDraftAttachment +import com.android.messaging.data.conversation.model.send.ConversationSendData +import com.android.messaging.data.conversation.repository.ConversationsRepository import com.android.messaging.datamodel.action.InsertNewMessageAction -import com.android.messaging.di.core.DefaultDispatcher +import com.android.messaging.datamodel.data.MessageData +import com.android.messaging.datamodel.data.ParticipantData +import com.android.messaging.di.core.IoDispatcher +import com.android.messaging.domain.conversation.usecase.draft.exception.BlankConversationIdException +import com.android.messaging.domain.conversation.usecase.draft.exception.ConversationRecipientsNotLoadedException +import com.android.messaging.domain.conversation.usecase.draft.exception.ConversationSimNotReadyException +import com.android.messaging.domain.conversation.usecase.draft.exception.DraftDispatchFailedException +import com.android.messaging.domain.conversation.usecase.draft.exception.EmptyConversationDraftException +import com.android.messaging.domain.conversation.usecase.draft.exception.MissingSelfPhoneNumberForGroupMmsException +import com.android.messaging.domain.conversation.usecase.draft.exception.SendConversationDraftException +import com.android.messaging.domain.conversation.usecase.draft.exception.TooManyVideoAttachmentsException +import com.android.messaging.domain.conversation.usecase.draft.exception.UnknownConversationRecipientException +import com.android.messaging.domain.conversation.usecase.draft.model.ConversationDraftSendProtocol +import com.android.messaging.sms.MmsUtils +import com.android.messaging.util.ContentType +import com.android.messaging.util.PhoneUtils import com.android.messaging.util.core.extension.unitFlow import javax.inject.Inject import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.withContext +import kotlinx.coroutines.flow.flowOn internal interface SendConversationDraft { operator fun invoke( @@ -19,15 +37,109 @@ internal interface SendConversationDraft { } internal class SendConversationDraftImpl @Inject constructor( + private val conversationsRepository: ConversationsRepository, + private val getConversationDraftSendProtocol: GetConversationDraftSendProtocol, private val conversationDraftMessageDataMapper: ConversationDraftMessageDataMapper, - @param:DefaultDispatcher - private val defaultDispatcher: CoroutineDispatcher, + @param:IoDispatcher + private val ioDispatcher: CoroutineDispatcher, ) : SendConversationDraft { override operator fun invoke( conversationId: String, draft: ConversationDraft, ): Flow { + return unitFlow { + try { + validateAndSendDraft( + conversationId = conversationId, + draft = draft, + ) + } catch (exception: CancellationException) { + throw exception + } catch (exception: SendConversationDraftException) { + throw exception + } catch (exception: Exception) { + throw DraftDispatchFailedException( + conversationId = conversationId, + cause = exception, + ) + } + }.flowOn(ioDispatcher) + } + + private fun validateAndSendDraft( + conversationId: String, + draft: ConversationDraft, + ) { + validateDraftBasics( + conversationId = conversationId, + draft = draft, + ) + + val sendData = conversationsRepository.getConversationSendData( + conversationId = conversationId, + requestedSelfParticipantId = draft.selfParticipantId, + ) ?: throw ConversationRecipientsNotLoadedException( + conversationId = conversationId, + ) + + val selfSubId = resolveSelfSubId(sendData = sendData) + val sendProtocol = getConversationDraftSendProtocol( + draft = draft, + sendData = sendData, + ) + val shouldSendAsMms = sendProtocol == ConversationDraftSendProtocol.MMS + + validateDraftForSend( + conversationId = conversationId, + draft = draft, + sendData = sendData, + selfSubId = selfSubId, + shouldSendAsMms = shouldSendAsMms, + ) + + val message = conversationDraftMessageDataMapper.map( + conversationId = conversationId, + draft = draft, + forceMms = shouldSendAsMms, + ) + + message.consolidateText() + + insertNewMessageWithLegacySelfLock( + message = message, + sendData = sendData, + ) + } + + private fun validateDraftForSend( + conversationId: String, + draft: ConversationDraft, + sendData: ConversationSendData, + selfSubId: Int, + shouldSendAsMms: Boolean, + ) { + validateKnownRecipients( + conversationId = conversationId, + sendData = sendData, + ) + + validateGroupMmsSelfNumber( + conversationId = conversationId, + sendData = sendData, + selfSubId = selfSubId, + shouldSendAsMms = shouldSendAsMms, + ) + validateVideoAttachmentLimit( + conversationId = conversationId, + attachments = draft.attachments, + ) + } + + private fun validateDraftBasics( + conversationId: String, + draft: ConversationDraft, + ) { if (conversationId.isBlank()) { throw BlankConversationIdException() } @@ -37,51 +149,99 @@ internal class SendConversationDraftImpl @Inject constructor( conversationId = conversationId, ) } + } - return unitFlow { - try { - withContext(context = defaultDispatcher) { - val message = conversationDraftMessageDataMapper.map( - conversationId = conversationId, - draft = draft, - ) - - message.consolidateText() - InsertNewMessageAction.insertNewMessage(message) - } - } catch (exception: CancellationException) { - throw exception - } catch (exception: Exception) { - throw DraftDispatchFailedException( + private fun validateKnownRecipients( + conversationId: String, + sendData: ConversationSendData, + ) { + if (!sendData.participants.isLoaded) { + throw ConversationRecipientsNotLoadedException( + conversationId = conversationId, + ) + } + + val hasUnknownSenders = sendData.participants.any { it.isUnknownSender } + + if (hasUnknownSenders) { + throw UnknownConversationRecipientException( + conversationId = conversationId, + ) + } + } + + private fun resolveSelfSubId(sendData: ConversationSendData): Int { + return sendData.selfParticipant?.subId ?: ParticipantData.DEFAULT_SELF_SUB_ID + } + + private fun validateGroupMmsSelfNumber( + conversationId: String, + sendData: ConversationSendData, + selfSubId: Int, + shouldSendAsMms: Boolean, + ) { + if (!sendData.metadata.isGroupConversation || !shouldSendAsMms) { + return + } + + try { + val selfPhoneNumber = PhoneUtils.get(selfSubId).getSelfRawNumber(true) + if (selfPhoneNumber.isNullOrBlank()) { + throw MissingSelfPhoneNumberForGroupMmsException( conversationId = conversationId, - cause = exception, + selfSubId = selfSubId, ) } + } catch (exception: IllegalStateException) { + throw ConversationSimNotReadyException( + conversationId = conversationId, + selfSubId = selfSubId, + cause = exception, + ) + } + } + + private fun validateVideoAttachmentLimit( + conversationId: String, + attachments: Iterable, + ) { + val videoAttachmentCount = attachments.count { attachment -> + ContentType.isVideoType(attachment.contentType) + } + + if (videoAttachmentCount > MmsUtils.MAX_VIDEO_ATTACHMENT_COUNT) { + throw TooManyVideoAttachmentsException( + conversationId = conversationId, + videoAttachmentCount = videoAttachmentCount, + ) + } + } + + private fun insertNewMessageWithLegacySelfLock( + message: MessageData, + sendData: ConversationSendData, + ) { + val selfParticipant = sendData.selfParticipant + + val systemDefaultSubId = PhoneUtils.getDefault().defaultSmsSubscriptionId + + val messageHasSelfParticipant = message.selfId != null + val conversationUsesDefaultSelf = selfParticipant?.isDefaultSelf == true + val systemDefaultSubIdIsResolved = systemDefaultSubId != + ParticipantData.DEFAULT_SELF_SUB_ID + + val shouldLockToSystemDefaultSubId = messageHasSelfParticipant && + conversationUsesDefaultSelf && + systemDefaultSubIdIsResolved + + when { + shouldLockToSystemDefaultSubId -> { + InsertNewMessageAction.insertNewMessage(message, systemDefaultSubId) + } + + else -> { + InsertNewMessageAction.insertNewMessage(message) + } } } } - -internal sealed class SendConversationDraftException( - message: String, - cause: Throwable? = null, -) : Exception(message, cause) - -internal class BlankConversationIdException : - SendConversationDraftException( - message = "Conversation id must not be blank.", - ) - -internal class EmptyConversationDraftException( - conversationId: String, -) : SendConversationDraftException( - message = "Draft must contain content before it can be sent " + - "for conversation $conversationId.", -) - -internal class DraftDispatchFailedException( - conversationId: String, - cause: Throwable, -) : SendConversationDraftException( - message = "Failed to enqueue outgoing draft for conversation $conversationId.", - cause = cause, -) diff --git a/src/com/android/messaging/domain/conversation/usecase/draft/exception/SendConversationDraftException.kt b/src/com/android/messaging/domain/conversation/usecase/draft/exception/SendConversationDraftException.kt new file mode 100644 index 00000000..465e625f --- /dev/null +++ b/src/com/android/messaging/domain/conversation/usecase/draft/exception/SendConversationDraftException.kt @@ -0,0 +1,63 @@ +package com.android.messaging.domain.conversation.usecase.draft.exception + +internal sealed class SendConversationDraftException( + message: String, + cause: Throwable? = null, +) : Exception(message, cause) + +internal class BlankConversationIdException : + SendConversationDraftException( + message = "Conversation id must not be blank.", + ) + +internal class EmptyConversationDraftException( + conversationId: String, +) : SendConversationDraftException( + message = "Draft must contain content before it can be sent " + + "for conversation $conversationId.", +) + +internal class ConversationRecipientsNotLoadedException( + conversationId: String, +) : SendConversationDraftException( + message = "Conversation recipients are not loaded for conversation $conversationId.", +) + +internal class UnknownConversationRecipientException( + conversationId: String, +) : SendConversationDraftException( + message = "Conversation $conversationId contains an unknown sender.", +) + +internal class MissingSelfPhoneNumberForGroupMmsException( + conversationId: String, + selfSubId: Int, +) : SendConversationDraftException( + message = "Missing self phone number for group MMS in conversation $conversationId " + + "on subId $selfSubId.", +) + +internal class ConversationSimNotReadyException( + conversationId: String, + selfSubId: Int, + cause: Throwable, +) : SendConversationDraftException( + message = "SIM is not ready for conversation $conversationId on subId $selfSubId.", + cause = cause, +) + +internal class TooManyVideoAttachmentsException( + conversationId: String, + videoAttachmentCount: Int, +) : SendConversationDraftException( + message = "Draft for conversation $conversationId has $videoAttachmentCount video " + + "attachments.", +) + +internal class DraftDispatchFailedException( + conversationId: String, + cause: Throwable, +) : SendConversationDraftException( + message = "Failed to enqueue outgoing draft for conversation $conversationId.", + cause = cause, +) From 6090aaf6b90a5ec4d605ee5cf44c5f929d725719 Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Tue, 28 Apr 2026 01:27:34 +0300 Subject: [PATCH 68/99] Show error message on draft validation failure --- res/values/strings.xml | 2 ++ .../delegate/ConversationDraftDelegate.kt | 34 +++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/res/values/strings.xml b/res/values/strings.xml index 9c3e7192..2296dd1d 100644 --- a/res/values/strings.xml +++ b/res/values/strings.xml @@ -577,6 +577,8 @@ Can\'t load attachment. Try again. Network is not ready. Try again. + + You can only send one video per message. Delete text Switch between entering text and numbers diff --git a/src/com/android/messaging/ui/conversation/v2/composer/delegate/ConversationDraftDelegate.kt b/src/com/android/messaging/ui/conversation/v2/composer/delegate/ConversationDraftDelegate.kt index e215badd..6266c1a0 100644 --- a/src/com/android/messaging/ui/conversation/v2/composer/delegate/ConversationDraftDelegate.kt +++ b/src/com/android/messaging/ui/conversation/v2/composer/delegate/ConversationDraftDelegate.kt @@ -11,12 +11,17 @@ 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.domain.conversation.usecase.draft.SendConversationDraft +import com.android.messaging.domain.conversation.usecase.draft.exception.ConversationSimNotReadyException +import com.android.messaging.domain.conversation.usecase.draft.exception.SendConversationDraftException +import com.android.messaging.domain.conversation.usecase.draft.exception.TooManyVideoAttachmentsException +import com.android.messaging.domain.conversation.usecase.draft.exception.UnknownConversationRecipientException import com.android.messaging.ui.conversation.v2.common.ConversationScreenDelegate import com.android.messaging.ui.conversation.v2.composer.model.ConversationDraftState import com.android.messaging.ui.conversation.v2.screen.model.ConversationScreenEffect import com.android.messaging.util.LogUtil import com.android.messaging.util.core.extension.unitFlow import javax.inject.Inject +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -403,6 +408,7 @@ internal class ConversationDraftDelegateImpl @Inject constructor( return runDraftOperationBoundary( operationName = "send draft", conversationId = sendRequest.conversationId, + onFailure = ::handleSendDraftFailure, ) { sendConversationDraft( conversationId = sendRequest.conversationId, @@ -418,6 +424,32 @@ internal class ConversationDraftDelegateImpl @Inject constructor( } } + private fun handleSendDraftFailure(exception: Throwable) { + // TODO: Add an extension that properly skip CancellationException manual handling + + val messageResId = when (exception) { + is CancellationException -> return + + is ConversationSimNotReadyException -> { + R.string.cant_send_message_without_active_subscription + } + + is TooManyVideoAttachmentsException -> { + R.string.cant_send_message_with_multiple_videos + } + + is UnknownConversationRecipientException -> R.string.unknown_sender + is SendConversationDraftException -> R.string.send_message_failure + else -> R.string.send_message_failure + } + + emitEffect( + effect = ConversationScreenEffect.ShowMessage( + messageResId = messageResId, + ), + ) + } + private fun createSaveDraftOperationFlow( operationName: String, saveRequest: DraftSaveRequest, @@ -595,6 +627,7 @@ internal class ConversationDraftDelegateImpl @Inject constructor( private fun runDraftOperationBoundary( operationName: String, conversationId: String?, + onFailure: ((Throwable) -> Unit)? = null, createFlow: () -> Flow, ): Flow { return flow { @@ -605,6 +638,7 @@ internal class ConversationDraftDelegateImpl @Inject constructor( "Failed to $operationName for conversation $conversationId", exception, ) + onFailure?.invoke(exception) } } From 4150c5acbe3b1d99cf1e11408943eefcab85bd3f Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Tue, 28 Apr 2026 14:12:18 +0300 Subject: [PATCH 69/99] Resend failed message on tap --- .../ConversationMessageSelectionDelegate.kt | 6 ++++ .../v2/messages/ui/ConversationMessages.kt | 6 ++++ .../ui/message/ConversationMessage.kt | 29 +++++++++++++++---- .../v2/screen/ConversationScreen.kt | 5 ++++ .../v2/screen/ConversationViewModel.kt | 5 ++++ 5 files changed, 46 insertions(+), 5 deletions(-) diff --git a/src/com/android/messaging/ui/conversation/v2/messages/delegate/ConversationMessageSelectionDelegate.kt b/src/com/android/messaging/ui/conversation/v2/messages/delegate/ConversationMessageSelectionDelegate.kt index 1b9ce052..5e19977f 100644 --- a/src/com/android/messaging/ui/conversation/v2/messages/delegate/ConversationMessageSelectionDelegate.kt +++ b/src/com/android/messaging/ui/conversation/v2/messages/delegate/ConversationMessageSelectionDelegate.kt @@ -42,6 +42,8 @@ internal interface ConversationMessageSelectionDelegate : fun onMessageLongClick(messageId: String) + fun onMessageResendClick(messageId: String) + fun onMessageSelectionActionClick(action: ConversationMessageSelectionAction) fun dismissDeleteMessageConfirmation() @@ -105,6 +107,10 @@ internal class ConversationMessageSelectionDelegateImpl @Inject constructor( toggleMessageSelection(messageId = messageId) } + override fun onMessageResendClick(messageId: String) { + resendMessageWhenActionRequirementsSatisfied(messageId = messageId) + } + override fun onMessageSelectionActionClick(action: ConversationMessageSelectionAction) { when (action) { ConversationMessageSelectionAction.Copy -> { diff --git a/src/com/android/messaging/ui/conversation/v2/messages/ui/ConversationMessages.kt b/src/com/android/messaging/ui/conversation/v2/messages/ui/ConversationMessages.kt index f13e986e..b0c931fb 100644 --- a/src/com/android/messaging/ui/conversation/v2/messages/ui/ConversationMessages.kt +++ b/src/com/android/messaging/ui/conversation/v2/messages/ui/ConversationMessages.kt @@ -63,6 +63,7 @@ internal fun ConversationMessages( onExternalUriClick: (String) -> Unit, onMessageClick: (String) -> Unit, onMessageLongClick: (String) -> Unit, + onMessageResendClick: (String) -> Unit, ) { val configuration = LocalConfiguration.current val displayMessages = remember(messages) { @@ -104,6 +105,7 @@ internal fun ConversationMessages( onExternalUriClick = onExternalUriClick, onMessageClick = onMessageClick, onMessageLongClick = onMessageLongClick, + onMessageResendClick = onMessageResendClick, ) } } @@ -153,6 +155,7 @@ private fun ConversationMessagesItem( onExternalUriClick: (String) -> Unit, onMessageClick: (String) -> Unit, onMessageLongClick: (String) -> Unit, + onMessageResendClick: (String) -> Unit, ) { val presentation = rememberConversationMessagesItemPresentation( message = message, @@ -178,6 +181,9 @@ private fun ConversationMessagesItem( onMessageLongClick = { onMessageLongClick(message.messageId) }, + onMessageResendClick = { + onMessageResendClick(message.messageId) + }, ) } } diff --git a/src/com/android/messaging/ui/conversation/v2/messages/ui/message/ConversationMessage.kt b/src/com/android/messaging/ui/conversation/v2/messages/ui/message/ConversationMessage.kt index eeb767dd..c05adcf3 100644 --- a/src/com/android/messaging/ui/conversation/v2/messages/ui/message/ConversationMessage.kt +++ b/src/com/android/messaging/ui/conversation/v2/messages/ui/message/ConversationMessage.kt @@ -66,6 +66,7 @@ internal fun ConversationMessage( onExternalUriClick: (String) -> Unit = {}, onMessageClick: () -> Unit = {}, onMessageLongClick: () -> Unit = {}, + onMessageResendClick: () -> Unit = {}, ) { BoxWithConstraints( modifier = modifier @@ -91,6 +92,7 @@ internal fun ConversationMessage( onExternalUriClick = onExternalUriClick, onMessageClick = onMessageClick, onMessageLongClick = onMessageLongClick, + onMessageResendClick = onMessageResendClick, ) } } @@ -226,6 +228,7 @@ private fun ConversationMessageContent( onExternalUriClick: (String) -> Unit, onMessageClick: () -> Unit, onMessageLongClick: () -> Unit, + onMessageResendClick: () -> Unit, ) { val hapticFeedback = LocalHapticFeedback.current val bubbleInteractionModifier = Modifier @@ -236,9 +239,15 @@ private fun ConversationMessageContent( .combinedClickable( enabled = true, onClick = { - if (isSelectionMode) { - hapticFeedback.performHapticFeedback(HapticFeedbackType.ContextClick) - onMessageClick() + when { + isSelectionMode -> { + hapticFeedback.performHapticFeedback(HapticFeedbackType.ContextClick) + onMessageClick() + } + + message.canResendMessage -> { + onMessageResendClick() + } } }, onLongClick = { @@ -264,6 +273,10 @@ private fun ConversationMessageContent( onMessageClick() } + message.canResendMessage -> { + onMessageResendClick() + } + else -> { onAttachmentClick(contentType, contentUri) } @@ -275,6 +288,10 @@ private fun ConversationMessageContent( onMessageClick() } + message.canResendMessage -> { + onMessageResendClick() + } + else -> { onExternalUriClick(uri) } @@ -794,8 +811,10 @@ private fun messageStatusTextResourceId(status: Status): Int? { Status.Outgoing.Sending -> R.string.message_status_sending Status.Outgoing.Resending -> R.string.message_status_send_retrying Status.Outgoing.AwaitingRetry -> R.string.message_status_failed - Status.Outgoing.Failed -> R.string.message_status_failed - Status.Outgoing.FailedEmergencyNumber -> R.string.message_status_failed + Status.Outgoing.Failed -> R.string.message_status_send_failed + Status.Outgoing.FailedEmergencyNumber -> { + R.string.message_status_send_failed_emergency_number + } Status.Incoming.YetToManualDownload -> R.string.message_status_download Status.Incoming.RetryingManualDownload -> R.string.message_status_downloading Status.Incoming.ManualDownloading -> R.string.message_status_downloading diff --git a/src/com/android/messaging/ui/conversation/v2/screen/ConversationScreen.kt b/src/com/android/messaging/ui/conversation/v2/screen/ConversationScreen.kt index 704a3aba..c97f8a74 100644 --- a/src/com/android/messaging/ui/conversation/v2/screen/ConversationScreen.kt +++ b/src/com/android/messaging/ui/conversation/v2/screen/ConversationScreen.kt @@ -251,6 +251,7 @@ internal fun ConversationScreen( onDismissMessageSelection = screenModel::dismissMessageSelection, onMessageClick = screenModel::onMessageClick, onMessageLongClick = screenModel::onMessageLongClick, + onMessageResendClick = screenModel::onMessageResendClick, onMessageSelectionActionClick = screenModel::onMessageSelectionActionClick, onOpenContactPicker = { contactPickerLauncher.launch(input = null) @@ -332,6 +333,7 @@ private fun ConversationScreenScaffold( onDismissMessageSelection: () -> Unit, onMessageClick: (String) -> Unit, onMessageLongClick: (String) -> Unit, + onMessageResendClick: (String) -> Unit, onMessageSelectionActionClick: (ConversationMessageSelectionAction) -> Unit, onNavigateBack: () -> Unit, onOpenContactPicker: () -> Unit, @@ -437,6 +439,7 @@ private fun ConversationScreenScaffold( onExternalUriClick = onExternalUriClick, onMessageClick = onMessageClick, onMessageLongClick = onMessageLongClick, + onMessageResendClick = onMessageResendClick, ) } @@ -511,6 +514,7 @@ private fun ConversationScreenContent( onExternalUriClick: (String) -> Unit, onMessageClick: (String) -> Unit, onMessageLongClick: (String) -> Unit, + onMessageResendClick: (String) -> Unit, ) { when (val messagesState = uiState.messages) { is ConversationMessagesUiState.Loading -> { @@ -555,6 +559,7 @@ private fun ConversationScreenContent( onExternalUriClick = onExternalUriClick, onMessageClick = onMessageClick, onMessageLongClick = onMessageLongClick, + onMessageResendClick = onMessageResendClick, ) } } diff --git a/src/com/android/messaging/ui/conversation/v2/screen/ConversationViewModel.kt b/src/com/android/messaging/ui/conversation/v2/screen/ConversationViewModel.kt index 44ef285f..9e8c8ecc 100644 --- a/src/com/android/messaging/ui/conversation/v2/screen/ConversationViewModel.kt +++ b/src/com/android/messaging/ui/conversation/v2/screen/ConversationViewModel.kt @@ -74,6 +74,7 @@ internal interface ConversationScreenModel { fun onMessageClick(messageId: String) fun onMessageLongClick(messageId: String) + fun onMessageResendClick(messageId: String) fun onMessageSelectionActionClick(action: ConversationMessageSelectionAction) fun onCallClick() @@ -455,6 +456,10 @@ internal class ConversationViewModel @Inject constructor( conversationMessageSelectionDelegate.onMessageLongClick(messageId = messageId) } + override fun onMessageResendClick(messageId: String) { + conversationMessageSelectionDelegate.onMessageResendClick(messageId = messageId) + } + override fun onMessageSelectionActionClick(action: ConversationMessageSelectionAction) { conversationMessageSelectionDelegate.onMessageSelectionActionClick(action = action) } From e37f8fefbf69aef90979757c816654d8d9d45acf Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Tue, 28 Apr 2026 16:23:35 +0300 Subject: [PATCH 70/99] Don't hide keyboard when attachments menu is shown --- .../v2/composer/ui/ConversationComposeBar.kt | 58 ++++++++++++++----- 1 file changed, 42 insertions(+), 16 deletions(-) diff --git a/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationComposeBar.kt b/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationComposeBar.kt index 54431e59..f61cdbcd 100644 --- a/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationComposeBar.kt +++ b/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationComposeBar.kt @@ -3,6 +3,8 @@ package com.android.messaging.ui.conversation.v2.composer.ui import androidx.annotation.StringRes import androidx.compose.animation.AnimatedContent import androidx.compose.animation.ContentTransform +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut @@ -56,6 +58,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.PopupProperties import com.android.messaging.R import com.android.messaging.ui.conversation.v2.CONVERSATION_ATTACHMENT_AUDIO_MENU_ITEM_TEST_TAG import com.android.messaging.ui.conversation.v2.CONVERSATION_ATTACHMENT_BUTTON_TEST_TAG @@ -73,6 +76,13 @@ import com.android.messaging.ui.core.AppTheme internal val AUDIO_RECORD_CANCEL_THRESHOLD = 96.dp internal val AUDIO_RECORD_LOCK_THRESHOLD = 72.dp +private const val CONTENT_SWAP_ENTER_FADE_DURATION_MILLIS = 160 +private const val CONTENT_SWAP_ENTER_SLIDE_DURATION_MILLIS = 220 +private const val CONTENT_SWAP_ENTER_SLIDE_OFFSET_DIVISOR = 10 +private const val CONTENT_SWAP_EXIT_FADE_DURATION_MILLIS = 120 +private const val CONTENT_SWAP_EXIT_SLIDE_DURATION_MILLIS = 180 +private const val CONTENT_SWAP_EXIT_SLIDE_OFFSET_DIVISOR = 12 + @Composable internal fun ConversationComposeBar( modifier: Modifier = Modifier, @@ -373,25 +383,38 @@ private fun ConversationComposePlaceholder() { } private fun contentSwapTransition(): ContentTransform { - return ( - fadeIn(animationSpec = tween(durationMillis = 160)) + - slideInHorizontally( - animationSpec = tween(durationMillis = 220), - initialOffsetX = { fullWidth -> - fullWidth / 10 - }, - ) - ).togetherWith( - fadeOut(animationSpec = tween(durationMillis = 120)) + - slideOutHorizontally( - animationSpec = tween(durationMillis = 180), - targetOffsetX = { fullWidth -> - -(fullWidth / 12) - }, - ), + val enterTransition = contentSwapEnterTransition() + val exitTransition = contentSwapExitTransition() + + return enterTransition.togetherWith(exitTransition) +} + +private fun contentSwapEnterTransition(): EnterTransition { + return fadeIn( + animationSpec = tween(durationMillis = CONTENT_SWAP_ENTER_FADE_DURATION_MILLIS), + ) + slideInHorizontally( + animationSpec = tween(durationMillis = CONTENT_SWAP_ENTER_SLIDE_DURATION_MILLIS), + initialOffsetX = ::contentSwapEnterOffset, ) } +private fun contentSwapExitTransition(): ExitTransition { + return fadeOut( + animationSpec = tween(durationMillis = CONTENT_SWAP_EXIT_FADE_DURATION_MILLIS), + ) + slideOutHorizontally( + animationSpec = tween(durationMillis = CONTENT_SWAP_EXIT_SLIDE_DURATION_MILLIS), + targetOffsetX = ::contentSwapExitOffset, + ) +} + +private fun contentSwapEnterOffset(fullWidth: Int): Int { + return fullWidth / CONTENT_SWAP_ENTER_SLIDE_OFFSET_DIVISOR +} + +private fun contentSwapExitOffset(fullWidth: Int): Int { + return -(fullWidth / CONTENT_SWAP_EXIT_SLIDE_OFFSET_DIVISOR) +} + @Composable private fun ConversationComposeAttachmentMenu( modifier: Modifier = Modifier, @@ -443,6 +466,9 @@ private fun ConversationComposeAttachmentMenu( x = 0.dp, y = (-8).dp, ), + properties = PopupProperties( + focusable = false, + ), ) { ConversationComposeAttachmentMenuItem( modifier = Modifier.testTag(CONVERSATION_ATTACHMENT_MEDIA_MENU_ITEM_TEST_TAG), From b0121db2accb544735e27e45d4b5d4277ec50ff6 Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Tue, 28 Apr 2026 17:01:24 +0300 Subject: [PATCH 71/99] Add conversation action button previews and improve readability --- .../ui/ConversationSendActionButton.kt | 127 +++++++++--------- 1 file changed, 67 insertions(+), 60 deletions(-) diff --git a/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationSendActionButton.kt b/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationSendActionButton.kt index f8f9c103..1e7d342b 100644 --- a/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationSendActionButton.kt +++ b/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationSendActionButton.kt @@ -1,6 +1,7 @@ package com.android.messaging.ui.conversation.v2.composer.ui import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.ContentTransform import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.RepeatMode @@ -26,6 +27,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.Send +import androidx.compose.material.icons.rounded.Mic import androidx.compose.material3.FilledIconButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButtonDefaults @@ -73,6 +75,39 @@ private data class ConversationSendActionButtonVisualState( val contentColor: Color, ) +private val SEND_ACTION_BUTTON_PULSE_SCALE_ANIMATION_SPEC = infiniteRepeatable( + animation = tween( + durationMillis = 1000, + easing = FastOutSlowInEasing, + ), + repeatMode = RepeatMode.Reverse, +) + +private val SEND_ACTION_BUTTON_BASE_SCALE_ANIMATION_SPEC = tween(durationMillis = 180) +private val SEND_ACTION_BUTTON_COLOR_ANIMATION_SPEC = tween(durationMillis = 220) +private val SEND_ACTION_BUTTON_ICON_ENTER_ANIMATION_SPEC = tween(durationMillis = 150) +private val SEND_ACTION_BUTTON_ICON_EXIT_ANIMATION_SPEC = tween(durationMillis = 120) + +private val SEND_ACTION_BUTTON_BACKDROP_PULSE_ANIMATION_SPEC = infiniteRepeatable( + animation = tween( + durationMillis = 2100, + easing = FastOutSlowInEasing, + ), + repeatMode = RepeatMode.Restart, +) + +private val SEND_ACTION_BUTTON_BACKDROP_DELAYED_PULSE_ANIMATION_SPEC = infiniteRepeatable( + animation = tween( + durationMillis = 2100, + easing = FastOutSlowInEasing, + ), + repeatMode = RepeatMode.Restart, + initialStartOffset = StartOffset( + offsetMillis = 1050, + offsetType = StartOffsetType.FastForward, + ), +) + @Composable internal fun ConversationSendActionButton( modifier: Modifier = Modifier, @@ -144,13 +179,7 @@ private fun animateConversationSendActionButtonVisualState( val pulseScale by pulseAnimation.animateFloat( initialValue = 1f, targetValue = 1.1f, - animationSpec = infiniteRepeatable( - animation = tween( - durationMillis = 1000, - easing = FastOutSlowInEasing, - ), - repeatMode = RepeatMode.Reverse, - ), + animationSpec = SEND_ACTION_BUTTON_PULSE_SCALE_ANIMATION_SPEC, label = "conversation_send_action_pulse_scale", ) @@ -160,7 +189,7 @@ private fun animateConversationSendActionButtonVisualState( isRecordGestureActive -> 0.95f else -> 1f }, - animationSpec = tween(durationMillis = 180), + animationSpec = SEND_ACTION_BUTTON_BASE_SCALE_ANIMATION_SPEC, label = "conversation_send_action_base_scale", ) @@ -174,7 +203,7 @@ private fun animateConversationSendActionButtonVisualState( isRecordingActive -> MaterialTheme.colorScheme.error else -> MaterialTheme.colorScheme.primary }, - animationSpec = tween(durationMillis = 220), + animationSpec = SEND_ACTION_BUTTON_COLOR_ANIMATION_SPEC, label = "conversation_send_action_container_color", ) @@ -183,7 +212,7 @@ private fun animateConversationSendActionButtonVisualState( isRecordingActive -> MaterialTheme.colorScheme.onError else -> MaterialTheme.colorScheme.onPrimary }, - animationSpec = tween(durationMillis = 220), + animationSpec = SEND_ACTION_BUTTON_COLOR_ANIMATION_SPEC, label = "conversation_send_action_content_color", ) @@ -509,19 +538,7 @@ private fun ConversationSendActionButtonIcon(mode: ConversationSendActionButtonM AnimatedContent( targetState = mode, transitionSpec = { - ( - fadeIn(animationSpec = tween(durationMillis = 150)) + - scaleIn( - animationSpec = tween(durationMillis = 150), - initialScale = 0.88f, - ) - ).togetherWith( - fadeOut(animationSpec = tween(durationMillis = 120)) + - scaleOut( - animationSpec = tween(durationMillis = 120), - targetScale = 1.08f, - ), - ) + conversationSendActionButtonIconContentTransform() }, label = "conversation_send_action_icon", ) { currentMode -> @@ -537,7 +554,7 @@ private fun ConversationSendActionButtonIcon(mode: ConversationSendActionButtonM ConversationSendActionButtonMode.Record -> { Icon( - painter = painterResource(id = R.drawable.ic_mp_audio_mic), + imageVector = Icons.Rounded.Mic, contentDescription = stringResource( id = R.string.audio_record_view_content_description, ), @@ -556,6 +573,28 @@ private fun ConversationSendActionButtonIcon(mode: ConversationSendActionButtonM } } +private fun conversationSendActionButtonIconContentTransform(): ContentTransform { + val fadeInTransition = fadeIn( + animationSpec = SEND_ACTION_BUTTON_ICON_ENTER_ANIMATION_SPEC, + ) + val scaleInTransition = scaleIn( + animationSpec = SEND_ACTION_BUTTON_ICON_ENTER_ANIMATION_SPEC, + initialScale = 0.9f, + ) + val enterTransition = fadeInTransition + scaleInTransition + + val fadeOutTransition = fadeOut( + animationSpec = SEND_ACTION_BUTTON_ICON_EXIT_ANIMATION_SPEC, + ) + val scaleOutTransition = scaleOut( + animationSpec = SEND_ACTION_BUTTON_ICON_EXIT_ANIMATION_SPEC, + targetScale = 1.1f, + ) + val exitTransition = fadeOutTransition + scaleOutTransition + + return enterTransition.togetherWith(exitTransition) +} + @Composable private fun ConversationSendActionButtonPulseBackdrop( isVisible: Boolean, @@ -571,60 +610,28 @@ private fun ConversationSendActionButtonPulseBackdrop( val outerPulseScale by pulseTransition.animateFloat( initialValue = 1f, targetValue = 2.9f, - animationSpec = infiniteRepeatable( - animation = tween( - durationMillis = 2100, - easing = FastOutSlowInEasing, - ), - repeatMode = RepeatMode.Restart, - ), + animationSpec = SEND_ACTION_BUTTON_BACKDROP_PULSE_ANIMATION_SPEC, label = "conversation_send_action_outer_pulse_scale", ) val outerPulseAlpha by pulseTransition.animateFloat( initialValue = 0.2f, targetValue = 0f, - animationSpec = infiniteRepeatable( - animation = tween( - durationMillis = 2100, - easing = FastOutSlowInEasing, - ), - repeatMode = RepeatMode.Restart, - ), + animationSpec = SEND_ACTION_BUTTON_BACKDROP_PULSE_ANIMATION_SPEC, label = "conversation_send_action_outer_pulse_alpha", ) val innerPulseScale by pulseTransition.animateFloat( initialValue = 1f, targetValue = 2.5f, - animationSpec = infiniteRepeatable( - animation = tween( - durationMillis = 2100, - easing = FastOutSlowInEasing, - ), - repeatMode = RepeatMode.Restart, - initialStartOffset = StartOffset( - offsetMillis = 1050, - offsetType = StartOffsetType.FastForward, - ), - ), + animationSpec = SEND_ACTION_BUTTON_BACKDROP_DELAYED_PULSE_ANIMATION_SPEC, label = "conversation_send_action_inner_pulse_scale", ) val innerPulseAlpha by pulseTransition.animateFloat( initialValue = 0.15f, targetValue = 0f, - animationSpec = infiniteRepeatable( - animation = tween( - durationMillis = 2100, - easing = FastOutSlowInEasing, - ), - repeatMode = RepeatMode.Restart, - initialStartOffset = StartOffset( - offsetMillis = 1050, - offsetType = StartOffsetType.FastForward, - ), - ), + animationSpec = SEND_ACTION_BUTTON_BACKDROP_DELAYED_PULSE_ANIMATION_SPEC, label = "conversation_send_action_inner_pulse_alpha", ) From b7000c192b7f46e35203ecd5f2a918f03be46b64 Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Tue, 28 Apr 2026 17:28:18 +0300 Subject: [PATCH 72/99] Do not hide keyboard when starting recording audio --- .../v2/composer/ui/ConversationComposeBar.kt | 253 +++++++++++++----- 1 file changed, 189 insertions(+), 64 deletions(-) diff --git a/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationComposeBar.kt b/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationComposeBar.kt index f61cdbcd..59cea989 100644 --- a/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationComposeBar.kt +++ b/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationComposeBar.kt @@ -15,6 +15,7 @@ import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.imePadding @@ -46,6 +47,7 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color @@ -55,6 +57,7 @@ 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.clearAndSetSemantics import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.dp @@ -228,6 +231,77 @@ private fun ConversationComposeInputContent( onAudioRecordingFinish: (Boolean) -> Unit, onSendClick: () -> Unit, ) { + val inputState = conversationComposeInputState( + audioRecording = audioRecording, + recordingGestureState = recordingGestureState, + shouldShowRecordAction = shouldShowRecordAction, + isRecordActionEnabled = isRecordActionEnabled, + isSendActionEnabled = isSendActionEnabled, + ) + + Row( + modifier = Modifier + .fillMaxWidth() + .padding( + horizontal = 12.dp, + vertical = 8.dp, + ), + horizontalArrangement = Arrangement.spacedBy( + space = 8.dp, + ), + verticalAlignment = Alignment.Bottom, + ) { + ConversationComposeMessageRecordingContent( + modifier = Modifier.weight(weight = 1f), + messageText = messageText, + durationMillis = audioRecording.durationMillis, + inputState = inputState, + isMessageFieldEnabled = isMessageFieldEnabled, + isAttachmentActionEnabled = isAttachmentActionEnabled, + isRecordActionEnabled = isRecordActionEnabled, + messageFieldFocusRequester = messageFieldFocusRequester, + presentation = presentation, + onContactAttachClick = onContactAttachClick, + onMediaPickerClick = onMediaPickerClick, + onLockedAudioRecordingStartRequest = onLockedAudioRecordingStartRequest, + onMessageTextChange = onMessageTextChange, + ) + + ConversationComposeSendAction( + modifier = Modifier + .testTag(CONVERSATION_SEND_BUTTON_TEST_TAG) + .semantics { + conversationShape = CONVERSATION_SEND_BUTTON_SHAPE_CIRCLE + }, + enabled = inputState.isRecordingControlEnabled, + mode = conversationComposeSendActionMode( + isRecordMode = inputState.isRecordMode, + isRecordingLocked = audioRecording.isLocked, + ), + isRecordingActive = inputState.isActiveRecording, + isRecordingLocked = audioRecording.isLocked, + shouldShowLockAffordance = inputState.isActiveRecording && !audioRecording.isLocked, + lockProgress = inputState.lockProgress, + onClick = onSendClick, + onLockedStopClick = { + onAudioRecordingFinish(false) + }, + onRecordGestureStart = onAudioRecordingStartRequest, + onRecordGestureMove = onAudioRecordingDrag, + onRecordGestureLock = onAudioRecordingLock, + onRecordGestureFinish = onAudioRecordingFinish, + ) + } +} + +@Composable +private fun conversationComposeInputState( + audioRecording: ConversationAudioRecordingUiState, + recordingGestureState: ConversationSendActionButtonGestureState, + shouldShowRecordAction: Boolean, + isRecordActionEnabled: Boolean, + isSendActionEnabled: Boolean, +): ConversationComposeInputState { val cancelThresholdPx = with(LocalDensity.current) { AUDIO_RECORD_CANCEL_THRESHOLD.toPx() } @@ -245,88 +319,118 @@ private fun ConversationComposeInputContent( .coerceIn(minimumValue = 0f, maximumValue = 1f) } } - - val isCancellationArmed = cancelProgress >= 1f val isActiveRecording = audioRecording.phase == ConversationAudioRecordingPhase.Recording val isRecordMode = shouldShowRecordAction || isActiveRecording + val isRecordingControlEnabled = when { isActiveRecording -> true isRecordMode -> isRecordActionEnabled else -> isSendActionEnabled } - Row( - modifier = Modifier - .fillMaxWidth() - .padding( - horizontal = 12.dp, - vertical = 8.dp, - ), - horizontalArrangement = Arrangement.spacedBy( - space = 8.dp, - ), - verticalAlignment = Alignment.Bottom, + return ConversationComposeInputState( + cancelProgress = cancelProgress, + lockProgress = lockProgress, + isCancellationArmed = cancelProgress >= 1f, + isActiveRecording = isActiveRecording, + isRecordMode = isRecordMode, + isRecordingControlEnabled = isRecordingControlEnabled, + ) +} + +@Composable +private fun ConversationComposeMessageRecordingContent( + modifier: Modifier = Modifier, + messageText: String, + durationMillis: Long, + inputState: ConversationComposeInputState, + isMessageFieldEnabled: Boolean, + isAttachmentActionEnabled: Boolean, + isRecordActionEnabled: Boolean, + messageFieldFocusRequester: FocusRequester?, + presentation: ConversationComposeBarPresentation, + onContactAttachClick: () -> Unit, + onMediaPickerClick: () -> Unit, + onLockedAudioRecordingStartRequest: () -> Unit, + onMessageTextChange: (String) -> Unit, +) { + Box( + modifier = modifier, ) { - AnimatedContent( - modifier = Modifier.weight(weight = 1f), - targetState = isActiveRecording, - transitionSpec = { - contentSwapTransition() + ConversationComposeMessageField( + modifier = Modifier.fillMaxWidth(), + value = messageText, + onValueChange = { updatedMessageText -> + if (!inputState.isActiveRecording) { + onMessageTextChange(updatedMessageText) + } }, - label = "conversation_compose_content", - ) { isRecording -> - when { - isRecording -> { + enabled = isMessageFieldEnabled, + isVisuallyHidden = inputState.isActiveRecording, + messageFieldFocusRequester = messageFieldFocusRequester, + presentation = presentation, + isAttachmentActionEnabled = isAttachmentActionEnabled, + isAudioRecordActionEnabled = isRecordActionEnabled, + onContactAttachClick = onContactAttachClick, + onMediaPickerClick = onMediaPickerClick, + onAudioAttachClick = onLockedAudioRecordingStartRequest, + ) + + ConversationAudioRecordingContentOverlay( + modifier = Modifier.matchParentSize(), + isActiveRecording = inputState.isActiveRecording, + durationMillis = durationMillis, + cancelProgress = inputState.cancelProgress, + isCancellationArmed = inputState.isCancellationArmed, + ) + } +} + +@Composable +private fun ConversationAudioRecordingContentOverlay( + modifier: Modifier = Modifier, + isActiveRecording: Boolean, + durationMillis: Long, + cancelProgress: Float, + isCancellationArmed: Boolean, +) { + AnimatedContent( + modifier = modifier, + targetState = isActiveRecording, + transitionSpec = { + contentSwapTransition() + }, + label = "conversation_compose_content", + ) { isRecording -> + when { + isRecording -> { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.BottomStart, + ) { ConversationAudioRecordingBar( - durationMillis = audioRecording.durationMillis, + durationMillis = durationMillis, cancelProgress = cancelProgress, isCancellationArmed = isCancellationArmed, ) } + } - else -> { - ConversationComposeMessageField( - modifier = Modifier, - value = messageText, - onValueChange = onMessageTextChange, - enabled = isMessageFieldEnabled, - messageFieldFocusRequester = messageFieldFocusRequester, - presentation = presentation, - isAttachmentActionEnabled = isAttachmentActionEnabled, - isAudioRecordActionEnabled = isRecordActionEnabled, - onContactAttachClick = onContactAttachClick, - onMediaPickerClick = onMediaPickerClick, - onAudioAttachClick = onLockedAudioRecordingStartRequest, - ) - } + else -> { + Box(modifier = Modifier.fillMaxSize()) } } + } +} - ConversationComposeSendAction( - modifier = Modifier - .testTag(CONVERSATION_SEND_BUTTON_TEST_TAG) - .semantics { - conversationShape = CONVERSATION_SEND_BUTTON_SHAPE_CIRCLE - }, - enabled = isRecordingControlEnabled, - mode = when { - isRecordMode && audioRecording.isLocked -> ConversationSendActionButtonMode.Stop - isRecordMode -> ConversationSendActionButtonMode.Record - else -> ConversationSendActionButtonMode.Send - }, - isRecordingActive = isActiveRecording, - isRecordingLocked = audioRecording.isLocked, - shouldShowLockAffordance = isActiveRecording && !audioRecording.isLocked, - lockProgress = lockProgress, - onClick = onSendClick, - onLockedStopClick = { - onAudioRecordingFinish(false) - }, - onRecordGestureStart = onAudioRecordingStartRequest, - onRecordGestureMove = onAudioRecordingDrag, - onRecordGestureLock = onAudioRecordingLock, - onRecordGestureFinish = onAudioRecordingFinish, - ) +private fun conversationComposeSendActionMode( + isRecordMode: Boolean, + isRecordingLocked: Boolean, +): ConversationSendActionButtonMode { + return when { + isRecordMode && isRecordingLocked -> ConversationSendActionButtonMode.Stop + isRecordMode -> ConversationSendActionButtonMode.Record + else -> ConversationSendActionButtonMode.Send } } @@ -335,6 +439,7 @@ private fun ConversationComposeMessageField( modifier: Modifier = Modifier, value: String, enabled: Boolean, + isVisuallyHidden: Boolean, messageFieldFocusRequester: FocusRequester?, presentation: ConversationComposeBarPresentation, isAttachmentActionEnabled: Boolean, @@ -348,11 +453,22 @@ private fun ConversationComposeMessageField( ?.let(Modifier::focusRequester) ?: Modifier + val recordingVisibilityModifier = when { + isVisuallyHidden -> { + Modifier + .alpha(alpha = 0f) + .clearAndSetSemantics {} + } + + else -> Modifier + } + TextField( modifier = modifier .then(focusRequesterModifier) .testTag(CONVERSATION_TEXT_FIELD_TEST_TAG) - .heightIn(min = 56.dp), + .heightIn(min = 56.dp) + .then(recordingVisibilityModifier), value = value, onValueChange = onValueChange, enabled = enabled, @@ -579,6 +695,15 @@ private data class ConversationComposeBarPresentation( val fieldColors: TextFieldColors, ) +private data class ConversationComposeInputState( + val cancelProgress: Float, + val lockProgress: Float, + val isCancellationArmed: Boolean, + val isActiveRecording: Boolean, + val isRecordMode: Boolean, + val isRecordingControlEnabled: Boolean, +) + @Composable private fun ConversationComposeBarPreviewContainer( content: @Composable () -> Unit, From 76a8df4f775325b70b031fcb3584713a90770d3f Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Tue, 28 Apr 2026 17:47:53 +0300 Subject: [PATCH 73/99] Add compose bar previews --- .../v2/composer/ui/ConversationComposeBar.kt | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationComposeBar.kt b/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationComposeBar.kt index 59cea989..bb232452 100644 --- a/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationComposeBar.kt +++ b/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationComposeBar.kt @@ -11,7 +11,6 @@ import androidx.compose.animation.fadeOut import androidx.compose.animation.slideInHorizontally import androidx.compose.animation.slideOutHorizontally import androidx.compose.animation.togetherWith -import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row @@ -74,7 +73,6 @@ import com.android.messaging.ui.conversation.v2.CONVERSATION_TEXT_FIELD_TEST_TAG import com.android.messaging.ui.conversation.v2.audio.model.ConversationAudioRecordingPhase import com.android.messaging.ui.conversation.v2.audio.model.ConversationAudioRecordingUiState import com.android.messaging.ui.conversation.v2.conversationShape -import com.android.messaging.ui.core.AppTheme internal val AUDIO_RECORD_CANCEL_THRESHOLD = 96.dp internal val AUDIO_RECORD_LOCK_THRESHOLD = 72.dp @@ -703,18 +701,3 @@ private data class ConversationComposeInputState( val isRecordMode: Boolean, val isRecordingControlEnabled: Boolean, ) - -@Composable -private fun ConversationComposeBarPreviewContainer( - content: @Composable () -> Unit, -) { - AppTheme { - Box( - modifier = Modifier - .background(color = MaterialTheme.colorScheme.background) - .padding(vertical = 24.dp), - ) { - content() - } - } -} From 24562bb04aa955c390b1c8d11ec79280c3e9ccb8 Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Tue, 28 Apr 2026 19:57:28 +0300 Subject: [PATCH 74/99] Add MMS indication in message composer --- .../conversation/v2/ConversationTestTags.kt | 1 + .../delegate/ConversationDraftDelegate.kt | 102 +++++++++++++++++- .../ConversationComposerUiStateMapper.kt | 7 +- .../model/ConversationComposerUiState.kt | 3 +- .../composer/model/ConversationDraftState.kt | 2 + .../v2/composer/ui/ConversationComposeBar.kt | 44 ++++++++ .../ui/ConversationComposerSection.kt | 3 + .../v2/screen/ConversationScreen.kt | 1 + 8 files changed, 160 insertions(+), 3 deletions(-) diff --git a/src/com/android/messaging/ui/conversation/v2/ConversationTestTags.kt b/src/com/android/messaging/ui/conversation/v2/ConversationTestTags.kt index cf1091e3..e2ede120 100644 --- a/src/com/android/messaging/ui/conversation/v2/ConversationTestTags.kt +++ b/src/com/android/messaging/ui/conversation/v2/ConversationTestTags.kt @@ -24,6 +24,7 @@ internal const val CONVERSATION_DELETE_CONVERSATION_BUTTON_TEST_TAG = internal const val CONVERSATION_LOADING_INDICATOR_TEST_TAG = "conversation_loading_indicator" internal const val CONVERSATION_MESSAGES_LIST_TEST_TAG = "conversation_messages_list" internal const val CONVERSATION_MEDIA_PICKER_OVERLAY_TEST_TAG = "conversation_media_picker_overlay" +internal const val CONVERSATION_MMS_INDICATOR_TEST_TAG = "conversation_mms_indicator" internal const val CONVERSATION_INLINE_AUDIO_ATTACHMENT_PLAY_BUTTON_TEST_TAG = "conversation_inline_audio_attachment_play_button" internal const val CONVERSATION_INLINE_AUDIO_ATTACHMENT_PROGRESS_TEST_TAG = diff --git a/src/com/android/messaging/ui/conversation/v2/composer/delegate/ConversationDraftDelegate.kt b/src/com/android/messaging/ui/conversation/v2/composer/delegate/ConversationDraftDelegate.kt index 6266c1a0..40e8babb 100644 --- a/src/com/android/messaging/ui/conversation/v2/composer/delegate/ConversationDraftDelegate.kt +++ b/src/com/android/messaging/ui/conversation/v2/composer/delegate/ConversationDraftDelegate.kt @@ -6,15 +6,19 @@ import com.android.messaging.data.conversation.model.draft.ConversationDraft import com.android.messaging.data.conversation.model.draft.ConversationDraftAttachment import com.android.messaging.data.conversation.model.draft.ConversationDraftPendingAttachment import com.android.messaging.data.conversation.repository.ConversationDraftsRepository +import com.android.messaging.data.conversation.repository.ConversationsRepository import com.android.messaging.di.core.ApplicationCoroutineScope import com.android.messaging.di.core.DefaultDispatcher +import com.android.messaging.di.core.IoDispatcher import com.android.messaging.domain.conversation.usecase.action.CheckConversationActionRequirements import com.android.messaging.domain.conversation.usecase.action.ConversationActionRequirementsResult +import com.android.messaging.domain.conversation.usecase.draft.GetConversationDraftSendProtocol import com.android.messaging.domain.conversation.usecase.draft.SendConversationDraft import com.android.messaging.domain.conversation.usecase.draft.exception.ConversationSimNotReadyException import com.android.messaging.domain.conversation.usecase.draft.exception.SendConversationDraftException import com.android.messaging.domain.conversation.usecase.draft.exception.TooManyVideoAttachmentsException import com.android.messaging.domain.conversation.usecase.draft.exception.UnknownConversationRecipientException +import com.android.messaging.domain.conversation.usecase.draft.model.ConversationDraftSendProtocol import com.android.messaging.ui.conversation.v2.common.ConversationScreenDelegate import com.android.messaging.ui.conversation.v2.composer.model.ConversationDraftState import com.android.messaging.ui.conversation.v2.screen.model.ConversationScreenEffect @@ -41,6 +45,7 @@ import kotlinx.coroutines.flow.emitAll import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.flow.onCompletion import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.transformLatest @@ -95,9 +100,13 @@ internal class ConversationDraftDelegateImpl @Inject constructor( private val applicationScope: CoroutineScope, private val checkConversationActionRequirements: CheckConversationActionRequirements, private val conversationDraftsRepository: ConversationDraftsRepository, + private val conversationsRepository: ConversationsRepository, + private val getConversationDraftSendProtocol: GetConversationDraftSendProtocol, private val sendConversationDraft: SendConversationDraft, @param:DefaultDispatcher private val defaultDispatcher: CoroutineDispatcher, + @param:IoDispatcher + private val ioDispatcher: CoroutineDispatcher, ) : ConversationDraftDelegate { private val _effects = MutableSharedFlow( @@ -129,6 +138,7 @@ internal class ConversationDraftDelegateImpl @Inject constructor( conversationIdFlow = conversationIdFlow, ) bindDraftAutosave(scope = scope) + bindDraftSendProtocol(scope = scope) } override fun onMessageTextChanged(messageText: String) { @@ -322,6 +332,21 @@ internal class ConversationDraftDelegateImpl @Inject constructor( } } + private fun bindDraftSendProtocol(scope: CoroutineScope) { + scope.launch(defaultDispatcher) { + observeDraftSendProtocol().collect { sendProtocol -> + _state.update { currentState -> + currentState.copy( + sendProtocol = when { + currentState.draft.hasContent -> sendProtocol + else -> ConversationDraftSendProtocol.SMS + }, + ) + } + } + } + } + private suspend fun resetDraftEditorState(conversationId: String?) { var previousDraftEditorState: DraftEditorState? = null @@ -544,10 +569,84 @@ internal class ConversationDraftDelegateImpl @Inject constructor( } } + private fun observeDraftSendProtocol(): Flow { + return draftEditorState + .map { currentDraftEditorState -> + currentDraftEditorState.conversationId to currentDraftEditorState.effectiveDraft + } + .distinctUntilChanged() + .debounce(timeoutMillis = DRAFT_SEND_PROTOCOL_DEBOUNCE_MILLIS) + .mapLatest { (conversationId, draft) -> + resolveDraftSendProtocol( + conversationId = conversationId, + draft = draft, + ) + } + .distinctUntilChanged() + } + + private suspend fun resolveDraftSendProtocol( + conversationId: String?, + draft: ConversationDraft, + ): ConversationDraftSendProtocol { + return try { + val resolvedConversationId = conversationId?.takeIf { it.isNotBlank() } + val sendData = when { + draft.hasContent && resolvedConversationId != null -> { + withContext(ioDispatcher) { + conversationsRepository.getConversationSendData( + conversationId = resolvedConversationId, + requestedSelfParticipantId = draft.selfParticipantId, + ) + } + } + + else -> null + } + + when (sendData) { + null -> fallbackDraftSendProtocol(draft = draft) + else -> { + getConversationDraftSendProtocol( + draft = draft, + sendData = sendData, + ) + } + } + } catch (exception: CancellationException) { + throw exception + } catch (exception: Throwable) { + LogUtil.e( + TAG, + "Failed to resolve draft send protocol for conversation $conversationId", + exception, + ) + + fallbackDraftSendProtocol(draft = draft) + } + } + + private fun fallbackDraftSendProtocol( + draft: ConversationDraft, + ): ConversationDraftSendProtocol { + return when { + draft.isMms -> ConversationDraftSendProtocol.MMS + else -> ConversationDraftSendProtocol.SMS + } + } + private fun updateDraftEditorState(transform: (DraftEditorState) -> DraftEditorState) { draftEditorState.update { currentDraftEditorState -> val updatedDraftEditorState = transform(currentDraftEditorState) - _state.value = updatedDraftEditorState.visibleState + val visibleState = updatedDraftEditorState.visibleState + val visibleSendProtocol = when { + visibleState.draft.hasContent -> _state.value.sendProtocol + else -> ConversationDraftSendProtocol.SMS + } + + _state.value = visibleState.copy( + sendProtocol = visibleSendProtocol, + ) updatedDraftEditorState } @@ -646,6 +745,7 @@ internal class ConversationDraftDelegateImpl @Inject constructor( private const val TAG = "ConversationDraftDelegate" private const val DRAFT_AUTOSAVE_DELAY_MILLIS = 300L + private const val DRAFT_SEND_PROTOCOL_DEBOUNCE_MILLIS = 250L } } diff --git a/src/com/android/messaging/ui/conversation/v2/composer/mapper/ConversationComposerUiStateMapper.kt b/src/com/android/messaging/ui/conversation/v2/composer/mapper/ConversationComposerUiStateMapper.kt index 158dd031..af97c2f3 100644 --- a/src/com/android/messaging/ui/conversation/v2/composer/mapper/ConversationComposerUiStateMapper.kt +++ b/src/com/android/messaging/ui/conversation/v2/composer/mapper/ConversationComposerUiStateMapper.kt @@ -2,6 +2,7 @@ package com.android.messaging.ui.conversation.v2.composer.mapper import com.android.messaging.data.conversation.model.metadata.ConversationComposerAvailability import com.android.messaging.data.conversation.model.metadata.ConversationSubscription +import com.android.messaging.domain.conversation.usecase.draft.model.ConversationDraftSendProtocol import com.android.messaging.ui.conversation.v2.audio.model.ConversationAudioRecordingPhase import com.android.messaging.ui.conversation.v2.audio.model.ConversationAudioRecordingUiState import com.android.messaging.ui.conversation.v2.composer.model.ComposerAttachmentUiModel @@ -33,6 +34,10 @@ internal class ConversationComposerUiStateMapperImpl @Inject constructor() : ): ConversationComposerUiState { val draft = draftState.draft val hasWorkingDraft = draft.hasContent + val visibleSendProtocol = when { + hasWorkingDraft -> draftState.sendProtocol + else -> ConversationDraftSendProtocol.SMS + } val isAttachmentActionEnabled = composerAvailability.isAttachmentActionEnabled && !draft.isCheckingDraft && @@ -69,7 +74,7 @@ internal class ConversationComposerUiStateMapperImpl @Inject constructor() : isSendEnabled = isSendEnabled, shouldShowRecordAction = shouldShowRecordAction, hasWorkingDraft = hasWorkingDraft, - isMms = draft.isMms, + sendProtocol = visibleSendProtocol, attachmentCount = draft.attachments.size, pendingAttachmentCount = draftState.pendingAttachments.size, messageCount = draft.messageCount, diff --git a/src/com/android/messaging/ui/conversation/v2/composer/model/ConversationComposerUiState.kt b/src/com/android/messaging/ui/conversation/v2/composer/model/ConversationComposerUiState.kt index 72ac38b5..81b6285c 100644 --- a/src/com/android/messaging/ui/conversation/v2/composer/model/ConversationComposerUiState.kt +++ b/src/com/android/messaging/ui/conversation/v2/composer/model/ConversationComposerUiState.kt @@ -2,6 +2,7 @@ package com.android.messaging.ui.conversation.v2.composer.model import androidx.compose.runtime.Immutable import com.android.messaging.data.conversation.model.metadata.ConversationComposerDisabledReason +import com.android.messaging.domain.conversation.usecase.draft.model.ConversationDraftSendProtocol import com.android.messaging.ui.conversation.v2.audio.model.ConversationAudioRecordingUiState import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf @@ -20,7 +21,7 @@ internal data class ConversationComposerUiState( val isSendEnabled: Boolean = false, val shouldShowRecordAction: Boolean = false, val hasWorkingDraft: Boolean = false, - val isMms: Boolean = false, + val sendProtocol: ConversationDraftSendProtocol = ConversationDraftSendProtocol.SMS, val attachmentCount: Int = 0, val pendingAttachmentCount: Int = 0, val messageCount: Int = 1, diff --git a/src/com/android/messaging/ui/conversation/v2/composer/model/ConversationDraftState.kt b/src/com/android/messaging/ui/conversation/v2/composer/model/ConversationDraftState.kt index c27c9308..afac75b2 100644 --- a/src/com/android/messaging/ui/conversation/v2/composer/model/ConversationDraftState.kt +++ b/src/com/android/messaging/ui/conversation/v2/composer/model/ConversationDraftState.kt @@ -2,8 +2,10 @@ package com.android.messaging.ui.conversation.v2.composer.model import com.android.messaging.data.conversation.model.draft.ConversationDraft import com.android.messaging.data.conversation.model.draft.ConversationDraftPendingAttachment +import com.android.messaging.domain.conversation.usecase.draft.model.ConversationDraftSendProtocol internal data class ConversationDraftState( val draft: ConversationDraft = ConversationDraft(), val pendingAttachments: List = emptyList(), + val sendProtocol: ConversationDraftSendProtocol = ConversationDraftSendProtocol.SMS, ) diff --git a/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationComposeBar.kt b/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationComposeBar.kt index bb232452..291d62a6 100644 --- a/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationComposeBar.kt +++ b/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationComposeBar.kt @@ -58,15 +58,18 @@ import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.stateDescription import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.window.PopupProperties import com.android.messaging.R +import com.android.messaging.domain.conversation.usecase.draft.model.ConversationDraftSendProtocol import com.android.messaging.ui.conversation.v2.CONVERSATION_ATTACHMENT_AUDIO_MENU_ITEM_TEST_TAG import com.android.messaging.ui.conversation.v2.CONVERSATION_ATTACHMENT_BUTTON_TEST_TAG import com.android.messaging.ui.conversation.v2.CONVERSATION_ATTACHMENT_CONTACT_MENU_ITEM_TEST_TAG import com.android.messaging.ui.conversation.v2.CONVERSATION_ATTACHMENT_MEDIA_MENU_ITEM_TEST_TAG import com.android.messaging.ui.conversation.v2.CONVERSATION_COMPOSE_BAR_TEST_TAG +import com.android.messaging.ui.conversation.v2.CONVERSATION_MMS_INDICATOR_TEST_TAG import com.android.messaging.ui.conversation.v2.CONVERSATION_SEND_BUTTON_SHAPE_CIRCLE import com.android.messaging.ui.conversation.v2.CONVERSATION_SEND_BUTTON_TEST_TAG import com.android.messaging.ui.conversation.v2.CONVERSATION_TEXT_FIELD_TEST_TAG @@ -89,6 +92,7 @@ internal fun ConversationComposeBar( modifier: Modifier = Modifier, audioRecording: ConversationAudioRecordingUiState, messageText: String, + sendProtocol: ConversationDraftSendProtocol, isMessageFieldEnabled: Boolean, isAttachmentActionEnabled: Boolean, isRecordActionEnabled: Boolean, @@ -128,6 +132,7 @@ internal fun ConversationComposeBar( ConversationComposeInputContent( audioRecording = audioRecording, messageText = messageText, + sendProtocol = sendProtocol, isMessageFieldEnabled = isMessageFieldEnabled, isAttachmentActionEnabled = isAttachmentActionEnabled, isRecordActionEnabled = isRecordActionEnabled, @@ -211,6 +216,7 @@ private fun conversationComposeBarTextFieldColors(): TextFieldColors { private fun ConversationComposeInputContent( audioRecording: ConversationAudioRecordingUiState, messageText: String, + sendProtocol: ConversationDraftSendProtocol, isMessageFieldEnabled: Boolean, isAttachmentActionEnabled: Boolean, isRecordActionEnabled: Boolean, @@ -252,6 +258,7 @@ private fun ConversationComposeInputContent( ConversationComposeMessageRecordingContent( modifier = Modifier.weight(weight = 1f), messageText = messageText, + sendProtocol = sendProtocol, durationMillis = audioRecording.durationMillis, inputState = inputState, isMessageFieldEnabled = isMessageFieldEnabled, @@ -340,6 +347,7 @@ private fun conversationComposeInputState( private fun ConversationComposeMessageRecordingContent( modifier: Modifier = Modifier, messageText: String, + sendProtocol: ConversationDraftSendProtocol, durationMillis: Long, inputState: ConversationComposeInputState, isMessageFieldEnabled: Boolean, @@ -364,6 +372,7 @@ private fun ConversationComposeMessageRecordingContent( } }, enabled = isMessageFieldEnabled, + sendProtocol = sendProtocol, isVisuallyHidden = inputState.isActiveRecording, messageFieldFocusRequester = messageFieldFocusRequester, presentation = presentation, @@ -437,6 +446,7 @@ private fun ConversationComposeMessageField( modifier: Modifier = Modifier, value: String, enabled: Boolean, + sendProtocol: ConversationDraftSendProtocol, isVisuallyHidden: Boolean, messageFieldFocusRequester: FocusRequester?, presentation: ConversationComposeBarPresentation, @@ -461,11 +471,23 @@ private fun ConversationComposeMessageField( else -> Modifier } + val mmsText = stringResource(id = R.string.mms_text) + val sendProtocolSemanticsModifier = when (sendProtocol) { + ConversationDraftSendProtocol.MMS -> { + Modifier.semantics { + stateDescription = mmsText + } + } + + ConversationDraftSendProtocol.SMS -> Modifier + } + TextField( modifier = modifier .then(focusRequesterModifier) .testTag(CONVERSATION_TEXT_FIELD_TEST_TAG) .heightIn(min = 56.dp) + .then(sendProtocolSemanticsModifier) .then(recordingVisibilityModifier), value = value, onValueChange = onValueChange, @@ -483,11 +505,33 @@ private fun ConversationComposeMessageField( onAudioAttachClick = onAudioAttachClick, ) }, + trailingIcon = when (sendProtocol) { + ConversationDraftSendProtocol.MMS -> { + { + MmsIndicator() + } + } + + ConversationDraftSendProtocol.SMS -> null + }, minLines = 1, maxLines = 4, ) } +@Composable +private fun MmsIndicator() { + Text( + modifier = Modifier + .padding(end = 12.dp) + .clearAndSetSemantics {} + .testTag(CONVERSATION_MMS_INDICATOR_TEST_TAG), + text = stringResource(id = R.string.mms_text), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.tertiary, + ) +} + @Composable private fun ConversationComposePlaceholder() { Text( diff --git a/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationComposerSection.kt b/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationComposerSection.kt index 9ce4cc72..7e4ca7ce 100644 --- a/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationComposerSection.kt +++ b/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationComposerSection.kt @@ -4,6 +4,7 @@ import androidx.compose.foundation.layout.Column import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester +import com.android.messaging.domain.conversation.usecase.draft.model.ConversationDraftSendProtocol import com.android.messaging.ui.conversation.v2.audio.model.ConversationAudioRecordingUiState import com.android.messaging.ui.conversation.v2.composer.model.ComposerAttachmentUiModel import kotlinx.collections.immutable.ImmutableList @@ -14,6 +15,7 @@ internal fun ConversationComposerSection( audioRecording: ConversationAudioRecordingUiState, attachments: ImmutableList, messageText: String, + sendProtocol: ConversationDraftSendProtocol, isMessageFieldEnabled: Boolean, isAttachmentActionEnabled: Boolean, isRecordActionEnabled: Boolean, @@ -46,6 +48,7 @@ internal fun ConversationComposerSection( ConversationComposeBar( audioRecording = audioRecording, messageText = messageText, + sendProtocol = sendProtocol, isMessageFieldEnabled = isMessageFieldEnabled, isAttachmentActionEnabled = isAttachmentActionEnabled, isRecordActionEnabled = isRecordActionEnabled, diff --git a/src/com/android/messaging/ui/conversation/v2/screen/ConversationScreen.kt b/src/com/android/messaging/ui/conversation/v2/screen/ConversationScreen.kt index c97f8a74..5e0c8514 100644 --- a/src/com/android/messaging/ui/conversation/v2/screen/ConversationScreen.kt +++ b/src/com/android/messaging/ui/conversation/v2/screen/ConversationScreen.kt @@ -405,6 +405,7 @@ private fun ConversationScreenScaffold( audioRecording = uiState.composer.audioRecording, attachments = uiState.composer.attachments, messageText = uiState.composer.messageText, + sendProtocol = uiState.composer.sendProtocol, isMessageFieldEnabled = uiState.composer.isMessageFieldEnabled, isAttachmentActionEnabled = uiState.composer.isAttachmentActionEnabled, isRecordActionEnabled = uiState.composer.isRecordActionEnabled, From 48f1c46efdb24e46daae7444070b22299504b910 Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Wed, 29 Apr 2026 22:01:20 +0300 Subject: [PATCH 75/99] Add photo picker draft attachment plumbing --- .../ConversationMessageDataDraftMapper.kt | 13 +- .../v2/mediapicker/model/AttachmentToSave.kt | 6 + .../model/PhotoPickerDraftAttachment.kt | 8 + .../model/PhotoPickerDraftAttachmentResult.kt | 11 + .../ConversationAttachmentRepository.kt | 280 +++++++++++++++++- .../ConversationMessageSelectionDelegate.kt | 3 +- 6 files changed, 304 insertions(+), 17 deletions(-) create mode 100644 src/com/android/messaging/ui/conversation/v2/mediapicker/model/AttachmentToSave.kt create mode 100644 src/com/android/messaging/ui/conversation/v2/mediapicker/model/PhotoPickerDraftAttachment.kt create mode 100644 src/com/android/messaging/ui/conversation/v2/mediapicker/model/PhotoPickerDraftAttachmentResult.kt diff --git a/src/com/android/messaging/data/conversation/mapper/ConversationMessageDataDraftMapper.kt b/src/com/android/messaging/data/conversation/mapper/ConversationMessageDataDraftMapper.kt index 20cbafd9..c91f6da0 100644 --- a/src/com/android/messaging/data/conversation/mapper/ConversationMessageDataDraftMapper.kt +++ b/src/com/android/messaging/data/conversation/mapper/ConversationMessageDataDraftMapper.kt @@ -5,8 +5,8 @@ import com.android.messaging.data.conversation.model.draft.ConversationDraftAtta import com.android.messaging.datamodel.data.MessageData import com.android.messaging.datamodel.data.MessagePartData import com.android.messaging.util.LogUtil -import javax.inject.Inject import kotlinx.collections.immutable.toImmutableList +import javax.inject.Inject internal interface ConversationMessageDataDraftMapper { fun map( @@ -44,6 +44,11 @@ internal class ConversationMessageDataDraftMapperImpl @Inject constructor() : val contentUri = part.contentUri?.toString()?.takeIf { it.isNotBlank() } return when { + contentUri?.isPhotoPickerUri == true -> { + LogUtil.w(TAG, "Dropping draft attachment backed by photo picker URI") + null + } + contentType != null && contentUri != null -> { ConversationDraftAttachment( contentType = contentType, @@ -69,7 +74,13 @@ internal class ConversationMessageDataDraftMapperImpl @Inject constructor() : return size.takeIf { it != MessagePartData.UNSPECIFIED_SIZE } } + private val String.isPhotoPickerUri: Boolean + get() { + return startsWith(prefix = PHOTO_PICKER_URI_PREFIX) + } + private companion object { private const val TAG = "ConversationMsgDataDraftMapper" + private const val PHOTO_PICKER_URI_PREFIX = "content://media/picker/" } } diff --git a/src/com/android/messaging/ui/conversation/v2/mediapicker/model/AttachmentToSave.kt b/src/com/android/messaging/ui/conversation/v2/mediapicker/model/AttachmentToSave.kt new file mode 100644 index 00000000..52e3ba8b --- /dev/null +++ b/src/com/android/messaging/ui/conversation/v2/mediapicker/model/AttachmentToSave.kt @@ -0,0 +1,6 @@ +package com.android.messaging.ui.conversation.v2.mediapicker.model + +internal data class AttachmentToSave( + val contentType: String, + val contentUri: String, +) diff --git a/src/com/android/messaging/ui/conversation/v2/mediapicker/model/PhotoPickerDraftAttachment.kt b/src/com/android/messaging/ui/conversation/v2/mediapicker/model/PhotoPickerDraftAttachment.kt new file mode 100644 index 00000000..c05f61f0 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/v2/mediapicker/model/PhotoPickerDraftAttachment.kt @@ -0,0 +1,8 @@ +package com.android.messaging.ui.conversation.v2.mediapicker.model + +import com.android.messaging.data.conversation.model.draft.ConversationDraftAttachment + +internal data class PhotoPickerDraftAttachment( + val sourceContentUri: String, + val draftAttachment: ConversationDraftAttachment, +) diff --git a/src/com/android/messaging/ui/conversation/v2/mediapicker/model/PhotoPickerDraftAttachmentResult.kt b/src/com/android/messaging/ui/conversation/v2/mediapicker/model/PhotoPickerDraftAttachmentResult.kt new file mode 100644 index 00000000..1a00bac8 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/v2/mediapicker/model/PhotoPickerDraftAttachmentResult.kt @@ -0,0 +1,11 @@ +package com.android.messaging.ui.conversation.v2.mediapicker.model + +internal sealed interface PhotoPickerDraftAttachmentResult { + data class Resolved( + val photoPickerDraftAttachment: PhotoPickerDraftAttachment, + ) : PhotoPickerDraftAttachmentResult + + data class Failed( + val sourceContentUri: String, + ) : PhotoPickerDraftAttachmentResult +} diff --git a/src/com/android/messaging/ui/conversation/v2/mediapicker/repository/ConversationAttachmentRepository.kt b/src/com/android/messaging/ui/conversation/v2/mediapicker/repository/ConversationAttachmentRepository.kt index 28907b9e..0d4a50ea 100644 --- a/src/com/android/messaging/ui/conversation/v2/mediapicker/repository/ConversationAttachmentRepository.kt +++ b/src/com/android/messaging/ui/conversation/v2/mediapicker/repository/ConversationAttachmentRepository.kt @@ -2,6 +2,8 @@ package com.android.messaging.ui.conversation.v2.mediapicker.repository import android.content.ContentResolver import android.content.ContentValues +import android.graphics.BitmapFactory +import android.media.MediaMetadataRetriever import android.net.Uri import android.os.Environment import android.provider.ContactsContract.Contacts @@ -12,19 +14,27 @@ import androidx.core.net.toUri import com.android.messaging.data.conversation.model.draft.ConversationDraftAttachment import com.android.messaging.datamodel.MediaScratchFileProvider import com.android.messaging.di.core.IoDispatcher +import com.android.messaging.ui.conversation.v2.mediapicker.model.AttachmentToSave +import com.android.messaging.ui.conversation.v2.mediapicker.model.PhotoPickerDraftAttachment +import com.android.messaging.ui.conversation.v2.mediapicker.model.PhotoPickerDraftAttachmentResult import com.android.messaging.util.ContentType import com.android.messaging.util.LogUtil import com.android.messaging.util.core.extension.typedFlow import com.android.messaging.util.core.extension.unitFlow -import java.io.IOException -import javax.inject.Inject import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOn +import java.io.IOException +import javax.inject.Inject internal interface ConversationAttachmentRepository { + fun createDraftAttachmentsFromPhotoPicker( + contentUris: List, + ): Flow + fun createDraftAttachmentFromContact( contactUri: String, ): Flow @@ -36,11 +46,6 @@ internal interface ConversationAttachmentRepository { fun saveAttachmentsToMediaStore( attachments: List, ): Flow - - data class AttachmentToSave( - val contentType: String, - val contentUri: String, - ) } internal class ConversationAttachmentRepositoryImpl @Inject constructor( @@ -49,6 +54,39 @@ internal class ConversationAttachmentRepositoryImpl @Inject constructor( private val ioDispatcher: CoroutineDispatcher, ) : ConversationAttachmentRepository { + override fun createDraftAttachmentsFromPhotoPicker( + contentUris: List, + ): Flow { + return flow { + for (contentUri in contentUris) { + val attachment = try { + createDraftAttachmentFromPhotoPicker(contentUri = contentUri) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + LogUtil.w(TAG, "Failed to resolve photo picker attachment $contentUri", e) + null + } + + val result = when (attachment) { + null -> { + PhotoPickerDraftAttachmentResult.Failed( + sourceContentUri = contentUri, + ) + } + + else -> { + PhotoPickerDraftAttachmentResult.Resolved( + photoPickerDraftAttachment = attachment, + ) + } + } + + emit(result) + } + }.flowOn(ioDispatcher) + } + override fun createDraftAttachmentFromContact( contactUri: String, ): Flow { @@ -85,7 +123,7 @@ internal class ConversationAttachmentRepositoryImpl @Inject constructor( } override fun saveAttachmentsToMediaStore( - attachments: List, + attachments: List, ): Flow { return typedFlow { saveAttachments(attachments = attachments) @@ -93,7 +131,7 @@ internal class ConversationAttachmentRepositoryImpl @Inject constructor( } private fun saveAttachments( - attachments: List, + attachments: List, ): SaveAttachmentsResult { var imageCount = 0 var videoCount = 0 @@ -130,6 +168,61 @@ internal class ConversationAttachmentRepositoryImpl @Inject constructor( ) } + private fun createDraftAttachmentFromPhotoPicker( + contentUri: String, + ): PhotoPickerDraftAttachment? { + val prepared = preparePhotoPickerContent(contentUri = contentUri) + ?: return null + + val metadata = resolveVisualAttachmentMetadata( + uri = prepared.scratchUri, + contentType = prepared.contentType, + ) + + return PhotoPickerDraftAttachment( + sourceContentUri = contentUri, + draftAttachment = ConversationDraftAttachment( + contentType = prepared.contentType, + contentUri = prepared.scratchUri.toString(), + width = metadata.width, + height = metadata.height, + durationMillis = metadata.durationMillis, + ), + ) + } + + private fun preparePhotoPickerContent( + contentUri: String, + ): PreparedPhotoPickerContent? { + if (contentUri.isBlank()) { + return null + } + + val sourceUri = contentUri.toUri() + val contentType = resolvePickerContentType(uri = sourceUri) + val isVisualContent = ContentType.isImageType(contentType) || + ContentType.isVideoType(contentType) + + return when { + !isVisualContent -> { + LogUtil.w(TAG, "Dropping unsupported photo picker attachment $contentUri") + null + } + + else -> { + copyPhotoPickerContentToScratchSpace( + sourceUri = sourceUri, + contentType = contentType, + )?.let { scratchUri -> + PreparedPhotoPickerContent( + scratchUri = scratchUri, + contentType = contentType, + ) + } + } + } + } + private fun saveOne( sourceUri: Uri, contentType: String, @@ -178,12 +271,11 @@ internal class ConversationAttachmentRepositoryImpl @Inject constructor( pendingUri: Uri, ): Boolean { return try { - contentResolver.openInputStream(sourceUri)?.use { source -> - contentResolver.openOutputStream(pendingUri)?.use { sink -> - source.copyTo(sink) - true - } - } ?: false + copyUriContentOrThrow( + sourceUri = sourceUri, + targetUri = pendingUri, + ) + true } catch (e: IOException) { LogUtil.e(TAG, "Copy to MediaStore failed for $sourceUri", e) false @@ -239,6 +331,153 @@ internal class ConversationAttachmentRepositoryImpl @Inject constructor( runCatching { contentResolver.update(pendingUri, values, null, null) } } + private fun copyPhotoPickerContentToScratchSpace( + sourceUri: Uri, + contentType: String, + ): Uri? { + val scratchUri = createScratchUri(contentType = contentType) + val isCopied = copyPhotoPickerContent( + sourceUri = sourceUri, + scratchUri = scratchUri, + ) + + return when { + isCopied -> scratchUri + + else -> { + deleteScratchContent(scratchUri = scratchUri) + null + } + } + } + + private fun createScratchUri(contentType: String): Uri { + return MimeTypeMap + .getSingleton() + .getExtensionFromMimeType(contentType) + .let(MediaScratchFileProvider::buildMediaScratchSpaceUri) + } + + private fun copyPhotoPickerContent(sourceUri: Uri, scratchUri: Uri): Boolean { + return try { + copyUriContentOrThrow( + sourceUri = sourceUri, + targetUri = scratchUri, + ) + + true + } catch (e: IOException) { + LogUtil.w(TAG, "Failed to copy photo picker content $sourceUri", e) + false + } catch (e: SecurityException) { + LogUtil.w(TAG, "Permission denied while copying photo picker content $sourceUri", e) + false + } + } + + private fun copyUriContentOrThrow(sourceUri: Uri, targetUri: Uri) { + val sourceStream = contentResolver.openInputStream(sourceUri) + ?: throw IOException("Unable to open input stream for $sourceUri") + + sourceStream.use { source -> + val targetStream = contentResolver.openOutputStream(targetUri) + ?: throw IOException("Unable to open output stream for $targetUri") + + targetStream.use(source::copyTo) + } + } + + private fun deleteScratchContent(scratchUri: Uri) { + runCatching { + contentResolver.delete(scratchUri, null, null) + } + } + + private fun resolvePickerContentType(uri: Uri): String { + val contentType = contentResolver + .getType(uri) + ?.takeIf { it.isNotBlank() } + + if (contentType != null) { + return contentType + } + + val extension = MimeTypeMap.getFileExtensionFromUrl(uri.toString()) + val extensionContentType = MimeTypeMap + .getSingleton() + .getMimeTypeFromExtension(extension) + ?.takeIf { it.isNotBlank() } + + return when { + extensionContentType != null -> extensionContentType + else -> ContentType.IMAGE_UNSPECIFIED + } + } + + private fun resolveVisualAttachmentMetadata( + uri: Uri, + contentType: String, + ): VisualAttachmentMetadata { + return when { + ContentType.isVideoType(contentType) -> resolveVideoAttachmentMetadata(uri = uri) + else -> resolveImageAttachmentMetadata(uri = uri) + } + } + + private fun resolveImageAttachmentMetadata(uri: Uri): VisualAttachmentMetadata { + val decodeBoundsOptions = BitmapFactory.Options().apply { + inJustDecodeBounds = true + } + + try { + contentResolver.openInputStream(uri)?.use { inputStream -> + BitmapFactory.decodeStream(inputStream, null, decodeBoundsOptions) + } + } catch (e: Exception) { + LogUtil.w(TAG, "Failed to decode photo picker image bounds for $uri", e) + } + + return VisualAttachmentMetadata( + width = decodeBoundsOptions.outWidth.takeIf { it > 0 }, + height = decodeBoundsOptions.outHeight.takeIf { it > 0 }, + durationMillis = null, + ) + } + + private fun resolveVideoAttachmentMetadata(uri: Uri): VisualAttachmentMetadata { + val retriever = MediaMetadataRetriever() + + return try { + contentResolver.openAssetFileDescriptor(uri, "r")?.use { fileDescriptor -> + retriever.setDataSource(fileDescriptor.fileDescriptor) + } + + VisualAttachmentMetadata( + width = retriever + .extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH) + ?.toIntOrNull() + ?.takeIf { it > 0 }, + height = retriever + .extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT) + ?.toIntOrNull() + ?.takeIf { it > 0 }, + durationMillis = retriever + .extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION) + ?.toLongOrNull() + ?.takeIf { it > 0 }, + ) + } catch (e: Exception) { + LogUtil.w(TAG, "Failed to decode photo picker video metadata for $uri", e) + VisualAttachmentMetadata() + } finally { + try { + retriever.release() + } catch (e: Exception) { + LogUtil.w(TAG, "Failed to release media metadata retriever", e) + } + } + } + private fun queryDraftAttachmentFromContact( contactUri: String, ): ConversationDraftAttachment? { @@ -280,6 +519,17 @@ internal class ConversationAttachmentRepositoryImpl @Inject constructor( } } +private data class PreparedPhotoPickerContent( + val scratchUri: Uri, + val contentType: String, +) + +private data class VisualAttachmentMetadata( + val width: Int? = null, + val height: Int? = null, + val durationMillis: Long? = null, +) + private enum class MediaKind { Image, Video, Audio, Other } private data class MediaStoreTarget( diff --git a/src/com/android/messaging/ui/conversation/v2/messages/delegate/ConversationMessageSelectionDelegate.kt b/src/com/android/messaging/ui/conversation/v2/messages/delegate/ConversationMessageSelectionDelegate.kt index 5e19977f..034f1201 100644 --- a/src/com/android/messaging/ui/conversation/v2/messages/delegate/ConversationMessageSelectionDelegate.kt +++ b/src/com/android/messaging/ui/conversation/v2/messages/delegate/ConversationMessageSelectionDelegate.kt @@ -10,6 +10,7 @@ import com.android.messaging.domain.conversation.usecase.action.CheckConversatio import com.android.messaging.domain.conversation.usecase.action.ConversationActionRequirementsResult import com.android.messaging.domain.conversation.usecase.forward.CreateForwardedMessage import com.android.messaging.ui.conversation.v2.common.ConversationScreenDelegate +import com.android.messaging.ui.conversation.v2.mediapicker.model.AttachmentToSave import com.android.messaging.ui.conversation.v2.mediapicker.repository.ConversationAttachmentRepository import com.android.messaging.ui.conversation.v2.messages.model.message.ConversationMessagePartUiModel import com.android.messaging.ui.conversation.v2.messages.model.message.ConversationMessageUiModel @@ -368,7 +369,7 @@ internal class ConversationMessageSelectionDelegateImpl @Inject constructor( null -> null else -> { - ConversationAttachmentRepository.AttachmentToSave( + AttachmentToSave( contentType = attachment.contentType, contentUri = contentUri.toString(), ) From 5b4731548cd544cc9b724a2b4f24fb253d6ada7f Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Wed, 29 Apr 2026 22:29:15 +0300 Subject: [PATCH 76/99] Use Embedded Photo Picker for conversation media --- app/build.gradle.kts | 2 + gradle/libs.versions.toml | 2 + gradle/verification-metadata.xml | 11 + .../v2/mediapicker/ConversationMediaPicker.kt | 120 ++++-- .../ConversationMediaPickerCaptureScene.kt | 72 ++++ .../ConversationMediaPickerDelegate.kt | 271 ++++++++++--- .../ConversationMediaPickerEffects.kt | 35 -- .../ConversationMediaPickerOverlay.kt | 91 ++--- .../ConversationMediaPickerPermission.kt | 31 -- .../ConversationMediaPickerScaffold.kt | 357 ++++++------------ .../ConversationMediaPickerSheetScaffold.kt | 92 +++++ .../gallery/ConversationMediaPickerGallery.kt | 235 ------------ .../review/ConversationMediaPickerReview.kt | 6 + .../ConversationMediaReviewPagerState.kt | 38 +- .../model/ConversationMediaPickerUiState.kt | 12 - .../v2/screen/ConversationScreen.kt | 7 +- .../v2/screen/ConversationViewModel.kt | 29 +- .../ConversationMediaPickerOverlayUiState.kt | 6 +- 18 files changed, 701 insertions(+), 716 deletions(-) create mode 100644 src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationMediaPickerCaptureScene.kt delete mode 100644 src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationMediaPickerEffects.kt create mode 100644 src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationMediaPickerSheetScaffold.kt delete mode 100644 src/com/android/messaging/ui/conversation/v2/mediapicker/component/gallery/ConversationMediaPickerGallery.kt delete mode 100644 src/com/android/messaging/ui/conversation/v2/mediapicker/model/ConversationMediaPickerUiState.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 66f5fcdb..d11b276b 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -161,6 +161,8 @@ dependencies { implementation(libs.androidx.navigation3.runtime) implementation(libs.androidx.navigation3.ui) + implementation(libs.androidx.photo.picker) + implementation(libs.coil.compose) implementation(libs.coil.network.okhttp) implementation(libs.glide) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f9ffb9ee..68ed526f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -24,6 +24,7 @@ lifecycle = "2.10.0" navigation3 = "1.1.0" paging = "3.4.2" palette = "1.0.0" +photo-picker = "1.0.0-alpha01" preference = "1.2.1" recyclerview = "1.4.0" @@ -70,6 +71,7 @@ androidx-navigation3-ui = { module = "androidx.navigation3:navigation3-ui", vers androidx-paging-compose = { module = "androidx.paging:paging-compose", version.ref = "paging" } androidx-paging-runtime = { module = "androidx.paging:paging-runtime", version.ref = "paging" } androidx-palette = { module = "androidx.palette:palette", version.ref = "palette" } +androidx-photo-picker = { module = "androidx.photopicker:photopicker-compose", version.ref = "photo-picker" } androidx-preference = { module = "androidx.preference:preference", version.ref = "preference" } androidx-recyclerview = { module = "androidx.recyclerview:recyclerview", version.ref = "recyclerview" } diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml index 22f6b44c..2163e88c 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -7996,5 +7996,16 @@ + + + + + + + + + + + diff --git a/src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationMediaPicker.kt b/src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationMediaPicker.kt index 32586b7c..6bafb197 100644 --- a/src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationMediaPicker.kt +++ b/src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationMediaPicker.kt @@ -1,50 +1,69 @@ package com.android.messaging.ui.conversation.v2.mediapicker +import android.net.Uri +import android.os.Build +import android.provider.MediaStore +import android.widget.photopicker.EmbeddedPhotoPickerFeatureInfo +import androidx.annotation.RequiresExtension +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.SheetValue import androidx.compose.material3.rememberBottomSheetScaffoldState import androidx.compose.material3.rememberStandardBottomSheetState import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Modifier +import androidx.core.net.toUri import androidx.lifecycle.compose.LocalLifecycleOwner -import com.android.messaging.data.media.model.ConversationMediaItem +import androidx.photopicker.compose.EmbeddedPhotoPicker +import androidx.photopicker.compose.ExperimentalPhotoPickerComposeApi +import androidx.photopicker.compose.rememberEmbeddedPhotoPickerState import com.android.messaging.ui.conversation.v2.composer.model.ComposerAttachmentUiModel import com.android.messaging.ui.conversation.v2.mediapicker.camera.BindConversationCameraLifecycleEffect import com.android.messaging.ui.conversation.v2.mediapicker.camera.rememberConversationCameraController import com.android.messaging.ui.conversation.v2.mediapicker.model.ConversationCapturedMedia -import com.android.messaging.ui.conversation.v2.mediapicker.model.ConversationMediaPickerUiState +import com.android.messaging.util.ContentType +import com.android.messaging.util.LogUtil import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.ImmutableMap import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.launch -@OptIn(ExperimentalMaterial3Api::class) +private const val TAG = "ConversationMediaPicker" + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalPhotoPickerComposeApi::class) +@RequiresExtension(extension = Build.VERSION_CODES.UPSIDE_DOWN_CAKE, version = 15) @Composable internal fun ConversationMediaPicker( modifier: Modifier = Modifier, - uiState: ConversationMediaPickerUiState, attachments: ImmutableList, conversationTitle: String?, isSendActionEnabled: Boolean, state: ConversationMediaPickerState, cameraPermissionGranted: Boolean, audioPermissionGranted: Boolean, - galleryPermissionGranted: Boolean, onClose: () -> Unit, onAttachmentPreviewClick: (ComposerAttachmentUiModel.Resolved.VisualMedia) -> Unit, onAttachmentCaptionChange: (String, String) -> Unit, onAttachmentRemove: (String) -> Unit, - onGalleryMediaConfirmed: (List) -> Unit, + photoPickerSourceContentUriByAttachmentContentUri: ImmutableMap, + onPhotoPickerMediaSelected: (List) -> Unit, + onPhotoPickerMediaDeselected: (List) -> Unit, onRequestAudioPermission: () -> Unit, onRequestCameraPermission: () -> Unit, - onRequestGalleryPermission: () -> Unit, onCapturedMediaReady: (ConversationCapturedMedia) -> Unit, onSendClick: () -> Unit, ) { val cameraController = rememberConversationCameraController() val lifecycleOwner = LocalLifecycleOwner.current + val coroutineScope = rememberCoroutineScope() val visualAttachments = remember(attachments) { attachments @@ -63,19 +82,63 @@ internal fun ConversationMediaPicker( bottomSheetState = sheetState, ) - var pendingSelectedMediaItem by remember { - mutableStateOf(value = null) + val embeddedPhotoPickerFeatureInfo = remember { + EmbeddedPhotoPickerFeatureInfo.Builder() + .setMaxSelectionLimit(MediaStore.getPickImagesMaxLimit()) + .setMimeTypes( + listOf( + ContentType.IMAGE_UNSPECIFIED, + ContentType.VIDEO_UNSPECIFIED, + ), + ) + .setOrderedSelection(true) + .build() } - HandlePendingGallerySelectionEffect( - pendingSelectedMediaItem = pendingSelectedMediaItem, - sheetState = sheetState, - onGalleryMediaConfirmed = onGalleryMediaConfirmed, - onShowReview = state::showReview, - onSelectionHandled = { - pendingSelectedMediaItem = null + val embeddedPhotoPickerState = rememberEmbeddedPhotoPickerState( + initialExpandedValue = false, + onSessionError = { + LogUtil.w(TAG, "Embedded photo picker session failed", it) + }, + onUriPermissionGranted = { uris -> + val contentUris = uris.map(Uri::toString) + onPhotoPickerMediaSelected(contentUris) + contentUris.lastOrNull()?.let(state::showReview) + }, + onUriPermissionRevoked = { uris -> + onPhotoPickerMediaDeselected(uris.map(Uri::toString)) + }, + onSelectionComplete = { + coroutineScope.launch(Dispatchers.Main.immediate) { + sheetState.partialExpand() + } }, ) + + LaunchedEffect(sheetState, embeddedPhotoPickerState) { + snapshotFlow { + sheetState.currentValue == SheetValue.Expanded || + sheetState.targetValue == SheetValue.Expanded + } + .distinctUntilChanged() + .collect { isExpanded -> + embeddedPhotoPickerState.setCurrentExpanded(expanded = isExpanded) + } + } + + val onPickerBackedAttachmentRemove = { contentUri: String -> + val sourceContentUri = photoPickerSourceContentUriByAttachmentContentUri[contentUri] + ?: contentUri + coroutineScope.launch(Dispatchers.Main.immediate) { + try { + embeddedPhotoPickerState.deselectUri(uri = sourceContentUri.toUri()) + } catch (e: IllegalStateException) { + LogUtil.w(TAG, "Unable to deselect photo picker URI $sourceContentUri", e) + } + } + onAttachmentRemove(contentUri) + } + BindConversationCameraLifecycleEffect( cameraController = cameraController, cameraPermissionGranted = cameraPermissionGranted, @@ -87,7 +150,15 @@ internal fun ConversationMediaPicker( modifier = modifier, cameraController = cameraController, scaffoldState = scaffoldState, - uiState = uiState, + photoPickerSheetContent = { + EmbeddedPhotoPicker( + modifier = Modifier + .background(color = MaterialTheme.colorScheme.surface) + .fillMaxSize(), + state = embeddedPhotoPickerState, + embeddedPhotoPickerFeatureInfo = embeddedPhotoPickerFeatureInfo, + ) + }, visualAttachments = visualAttachments, conversationTitle = conversationTitle, captureMode = state.captureMode, @@ -97,17 +168,14 @@ internal fun ConversationMediaPicker( isSendActionEnabled = isSendActionEnabled, cameraPermissionGranted = cameraPermissionGranted, audioPermissionGranted = audioPermissionGranted, - galleryPermissionGranted = galleryPermissionGranted, onClose = onClose, onAttachmentPreviewClick = onAttachmentPreviewClick, onAttachmentCaptionChange = onAttachmentCaptionChange, - onAttachmentRemove = onAttachmentRemove, - onGalleryMediaClick = { mediaItem -> - pendingSelectedMediaItem = mediaItem - }, + onAttachmentRemove = onPickerBackedAttachmentRemove, + photoPickerSourceContentUriByAttachmentContentUri = + photoPickerSourceContentUriByAttachmentContentUri, onRequestAudioPermission = onRequestAudioPermission, onRequestCameraPermission = onRequestCameraPermission, - onRequestGalleryPermission = onRequestGalleryPermission, onCapturedMediaReady = onCapturedMediaReady, onSendClick = onSendClick, onShowReview = state::showReview, diff --git a/src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationMediaPickerCaptureScene.kt b/src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationMediaPickerCaptureScene.kt new file mode 100644 index 00000000..a9c9ab49 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationMediaPickerCaptureScene.kt @@ -0,0 +1,72 @@ +package com.android.messaging.ui.conversation.v2.mediapicker + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.android.messaging.ui.conversation.v2.mediapicker.camera.ConversationCameraController +import com.android.messaging.ui.conversation.v2.mediapicker.component.capture.ConversationMediaCameraPreviewSurface +import com.android.messaging.ui.conversation.v2.mediapicker.model.ConversationCapturedMedia + +@Composable +internal fun ConversationMediaPickerCaptureScene( + modifier: Modifier = Modifier, + cameraController: ConversationCameraController, + contentPadding: PaddingValues, + captureMode: ConversationCaptureMode, + cameraPermissionGranted: Boolean, + audioPermissionGranted: Boolean, + onClose: () -> Unit, + onRequestAudioPermission: () -> Unit, + onRequestCameraPermission: () -> Unit, + onCapturedMediaReady: (ConversationCapturedMedia) -> Unit, + onShowReview: (String) -> Unit, + onCaptureModeChange: (ConversationCaptureMode) -> Unit, +) { + Box( + modifier = modifier + .fillMaxSize(), + ) { + ConversationMediaCameraPreviewRoute( + modifier = Modifier + .fillMaxSize(), + cameraController = cameraController, + cameraPermissionGranted = cameraPermissionGranted, + onRequestCameraPermission = onRequestCameraPermission, + ) + + ConversationMediaCaptureRoute( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues = contentPadding), + cameraController = cameraController, + audioPermissionGranted = audioPermissionGranted, + captureMode = captureMode, + onClose = onClose, + onRequestAudioPermission = onRequestAudioPermission, + onShowReview = onShowReview, + onCapturedMediaReady = onCapturedMediaReady, + onCaptureModeChange = onCaptureModeChange, + ) + } +} + +@Composable +private fun ConversationMediaCameraPreviewRoute( + modifier: Modifier = Modifier, + cameraController: ConversationCameraController, + cameraPermissionGranted: Boolean, + onRequestCameraPermission: () -> Unit, +) { + val surfaceRequest = cameraController.surfaceRequest.collectAsStateWithLifecycle() + + ConversationMediaCameraPreviewSurface( + modifier = modifier, + cameraPermissionGranted = cameraPermissionGranted, + surfaceRequest = surfaceRequest.value, + onRequestCameraPermission = onRequestCameraPermission, + ) +} diff --git a/src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationMediaPickerDelegate.kt b/src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationMediaPickerDelegate.kt index 817dd3c8..4913406c 100644 --- a/src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationMediaPickerDelegate.kt +++ b/src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationMediaPickerDelegate.kt @@ -1,18 +1,16 @@ package com.android.messaging.ui.conversation.v2.mediapicker -import com.android.messaging.data.media.model.ConversationMediaItem -import com.android.messaging.data.media.repository.ConversationMediaRepository +import com.android.messaging.R import com.android.messaging.di.core.DefaultDispatcher -import com.android.messaging.ui.conversation.v2.common.ConversationScreenDelegate import com.android.messaging.ui.conversation.v2.composer.delegate.ConversationDraftDelegate import com.android.messaging.ui.conversation.v2.mediapicker.mapper.ConversationDraftAttachmentMapper import com.android.messaging.ui.conversation.v2.mediapicker.model.ConversationCapturedMedia -import com.android.messaging.ui.conversation.v2.mediapicker.model.ConversationMediaPickerUiState +import com.android.messaging.ui.conversation.v2.mediapicker.model.PhotoPickerDraftAttachment +import com.android.messaging.ui.conversation.v2.mediapicker.model.PhotoPickerDraftAttachmentResult import com.android.messaging.ui.conversation.v2.mediapicker.repository.ConversationAttachmentRepository import com.android.messaging.ui.conversation.v2.screen.model.ConversationScreenEffect import com.android.messaging.util.LogUtil -import javax.inject.Inject -import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job @@ -24,18 +22,27 @@ import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.collections.immutable.ImmutableMap +import kotlinx.collections.immutable.persistentMapOf +import kotlinx.collections.immutable.toPersistentMap +import javax.inject.Inject -internal interface ConversationMediaPickerDelegate : - ConversationScreenDelegate { +internal interface ConversationMediaPickerDelegate { val effects: Flow + val photoPickerSourceContentUriByAttachmentContentUri: StateFlow> + + fun bind( + scope: CoroutineScope, + conversationIdFlow: StateFlow, + ) - fun onGalleryMediaConfirmed(mediaItems: List) + fun onPhotoPickerMediaSelected(contentUris: List) - fun onGalleryVisibilityChanged(isVisible: Boolean) + fun onPhotoPickerMediaDeselected(contentUris: List) fun onCapturedMediaReady(capturedMedia: ConversationCapturedMedia) @@ -52,7 +59,6 @@ internal class ConversationMediaPickerDelegateImpl @Inject constructor( private val conversationDraftDelegate: ConversationDraftDelegate, private val conversationAttachmentRepository: ConversationAttachmentRepository, private val conversationDraftAttachmentMapper: ConversationDraftAttachmentMapper, - private val conversationMediaRepository: ConversationMediaRepository, @param:DefaultDispatcher private val defaultDispatcher: CoroutineDispatcher, ) : ConversationMediaPickerDelegate { @@ -60,11 +66,18 @@ internal class ConversationMediaPickerDelegateImpl @Inject constructor( private val _effects = MutableSharedFlow( extraBufferCapacity = 1, ) - private val _state = MutableStateFlow(ConversationMediaPickerUiState()) + private val photoPickerAttachmentLock = Any() + private val pendingAttachmentJobs = mutableMapOf() + private val photoPickerContentUris = mutableSetOf() + private val attachmentContentUriByPhotoPickerContentUri = mutableMapOf() + private val photoPickerContentUriByAttachmentContentUri = mutableMapOf() + private val _photoPickerSourceContentUriByAttachmentContentUri = + MutableStateFlow>(persistentMapOf()) override val effects = _effects.asSharedFlow() - override val state = _state.asStateFlow() + override val photoPickerSourceContentUriByAttachmentContentUri = + _photoPickerSourceContentUriByAttachmentContentUri.asStateFlow() private var boundScope: CoroutineScope? = null @@ -80,63 +93,140 @@ internal class ConversationMediaPickerDelegateImpl @Inject constructor( scope.launch(defaultDispatcher) { conversationIdFlow + .drop(count = 1) .collect { cancelPendingAttachmentJobs() } } } - override fun onGalleryMediaConfirmed(mediaItems: List) { - if (mediaItems.isEmpty()) { - return - } + override fun onPhotoPickerMediaSelected(contentUris: List) { + claimNewPhotoPickerContentUris(contentUris = contentUris) + .takeIf { it.isNotEmpty() } + ?.let(::launchPhotoPickerAttachmentResolution) + } - conversationDraftDelegate.addAttachments( - attachments = mediaItems.map { mediaItem -> - conversationDraftAttachmentMapper.map( - mediaItem = mediaItem, - ) - }, - ) + private fun claimNewPhotoPickerContentUris(contentUris: List): List { + return synchronized(photoPickerAttachmentLock) { + contentUris.filter { contentUri -> + contentUri.isNotBlank() && photoPickerContentUris.add(contentUri) + } + } } - override fun onGalleryVisibilityChanged(isVisible: Boolean) { - if (!isVisible) { - return + private fun launchPhotoPickerAttachmentResolution(contentUris: List) { + boundScope?.launch(defaultDispatcher) { + conversationAttachmentRepository + .createDraftAttachmentsFromPhotoPicker(contentUris = contentUris) + .catch { throwable -> + handlePhotoPickerAttachmentResolutionException( + contentUris = contentUris, + throwable = throwable, + ) + } + .collect { result -> + handlePhotoPickerAttachmentResult(result = result) + } } + } - if (state.value.isLoadingGallery || state.value.galleryItems.isNotEmpty()) { - return + private suspend fun handlePhotoPickerAttachmentResolutionException( + contentUris: List, + throwable: Throwable, + ) { + if (throwable is CancellationException) { + throw throwable } - boundScope?.launch(defaultDispatcher) { - _state.update { currentMediaPickerUiState -> - currentMediaPickerUiState.copy(isLoadingGallery = true) + LogUtil.w(TAG, "Unable to resolve photo picker attachments", throwable) + + releasePhotoPickerContentUris(contentUris = contentUris) + emitAttachmentLoadFailedEffect() + } + + private suspend fun handlePhotoPickerAttachmentResult( + result: PhotoPickerDraftAttachmentResult, + ) { + when (result) { + is PhotoPickerDraftAttachmentResult.Resolved -> { + onPhotoPickerAttachmentResolved(result.photoPickerDraftAttachment) } - conversationMediaRepository - .getRecentMedia() - .map { it.toImmutableList() } - .catch { throwable -> - LogUtil.w(TAG, "Unable to query gallery items", throwable) + is PhotoPickerDraftAttachmentResult.Failed -> { + val wasSelected = releasePhotoPickerContentUri(result.sourceContentUri) - _state.update { currentMediaPickerUiState -> - currentMediaPickerUiState.copy( - isLoadingGallery = false, - ) - } - } - .collect { galleryItems -> - _state.update { currentMediaPickerUiState -> - currentMediaPickerUiState.copy( - galleryItems = galleryItems, - isLoadingGallery = false, - ) - } + if (wasSelected) { + emitAttachmentLoadFailedEffect() } + } + } + } + + private fun onPhotoPickerAttachmentResolved( + photoPickerAttachment: PhotoPickerDraftAttachment, + ) { + val shouldDeleteTemporaryAttachment = synchronized(photoPickerAttachmentLock) { + val sourceContentUri = photoPickerAttachment.sourceContentUri + if (!photoPickerContentUris.contains(sourceContentUri)) { + return@synchronized true + } + + registerPhotoPickerAttachment(photoPickerAttachment) + conversationDraftDelegate.addAttachments( + attachments = listOf( + photoPickerAttachment.draftAttachment, + ), + ) + + false + } + + if (shouldDeleteTemporaryAttachment) { + deleteTemporaryAttachment( + contentUri = photoPickerAttachment.draftAttachment.contentUri, + ) } } + private fun releasePhotoPickerContentUri(contentUri: String): Boolean { + return synchronized(photoPickerAttachmentLock) { + photoPickerContentUris.remove(contentUri) + } + } + + private fun releasePhotoPickerContentUris(contentUris: List) { + synchronized(photoPickerAttachmentLock) { + photoPickerContentUris.removeAll(contentUris.toSet()) + } + } + + private suspend fun emitAttachmentLoadFailedEffect() { + _effects.emit( + ConversationScreenEffect.ShowMessage( + messageResId = R.string.fail_to_load_attachment, + ), + ) + } + + override fun onPhotoPickerMediaDeselected(contentUris: List) { + contentUris + .filter { it.isNotBlank() } + .forEach { photoPickerContentUri -> + val attachmentContentUri = synchronized(photoPickerAttachmentLock) { + val registeredContentUri = unregisterPhotoPickerAttachmentByPickerUri( + photoPickerContentUri = photoPickerContentUri, + ) + + photoPickerContentUris.remove(photoPickerContentUri) + + registeredContentUri + } ?: photoPickerContentUri + + conversationDraftDelegate.removeAttachment(attachmentContentUri) + deleteTemporaryAttachment(attachmentContentUri) + } + } + override fun onCapturedMediaReady(capturedMedia: ConversationCapturedMedia) { conversationDraftDelegate.addAttachments( attachments = listOf( @@ -160,7 +250,10 @@ internal class ConversationMediaPickerDelegateImpl @Inject constructor( } override fun onRemovePendingAttachment(pendingAttachmentId: String) { - pendingAttachmentJobs.remove(pendingAttachmentId)?.cancel() + synchronized(photoPickerAttachmentLock) { + pendingAttachmentJobs.remove(pendingAttachmentId) + }?.cancel() + conversationDraftDelegate.removePendingAttachment( pendingAttachmentId = pendingAttachmentId, ) @@ -169,10 +262,14 @@ internal class ConversationMediaPickerDelegateImpl @Inject constructor( override fun onRemoveResolvedAttachment(contentUri: String) { conversationDraftDelegate.removeAttachment(contentUri = contentUri) - boundScope?.launch(defaultDispatcher) { - conversationAttachmentRepository - .deleteTemporaryAttachment(contentUri = contentUri) - .collect() + deleteTemporaryAttachment(contentUri = contentUri) + + synchronized(photoPickerAttachmentLock) { + unregisterPhotoPickerAttachmentByAttachmentUri( + attachmentContentUri = contentUri, + )?.also { photoPickerContentUri -> + photoPickerContentUris.remove(photoPickerContentUri) + } } } @@ -181,8 +278,66 @@ internal class ConversationMediaPickerDelegateImpl @Inject constructor( } private fun cancelPendingAttachmentJobs() { - pendingAttachmentJobs.values.forEach { it.cancel() } - pendingAttachmentJobs.clear() + val jobs = synchronized(photoPickerAttachmentLock) { + val jobs = pendingAttachmentJobs.values.toList() + pendingAttachmentJobs.clear() + photoPickerContentUris.clear() + attachmentContentUriByPhotoPickerContentUri.clear() + photoPickerContentUriByAttachmentContentUri.clear() + publishPhotoPickerSourceContentUrisLocked() + + jobs + } + + jobs.forEach { it.cancel() } + } + + private fun registerPhotoPickerAttachment(photoPickerAttachment: PhotoPickerDraftAttachment) { + val sourceContentUri = photoPickerAttachment.sourceContentUri + val attachmentContentUri = photoPickerAttachment.draftAttachment.contentUri + + attachmentContentUriByPhotoPickerContentUri[sourceContentUri] = attachmentContentUri + photoPickerContentUriByAttachmentContentUri[attachmentContentUri] = sourceContentUri + publishPhotoPickerSourceContentUrisLocked() + } + + private fun unregisterPhotoPickerAttachmentByPickerUri( + photoPickerContentUri: String, + ): String? { + val attachmentContentUri = attachmentContentUriByPhotoPickerContentUri + .remove(photoPickerContentUri) + ?: return null + + photoPickerContentUriByAttachmentContentUri.remove(attachmentContentUri) + publishPhotoPickerSourceContentUrisLocked() + + return attachmentContentUri + } + + private fun unregisterPhotoPickerAttachmentByAttachmentUri( + attachmentContentUri: String, + ): String? { + val photoPickerContentUri = photoPickerContentUriByAttachmentContentUri + .remove(attachmentContentUri) + ?: return null + + attachmentContentUriByPhotoPickerContentUri.remove(photoPickerContentUri) + publishPhotoPickerSourceContentUrisLocked() + + return photoPickerContentUri + } + + private fun publishPhotoPickerSourceContentUrisLocked() { + _photoPickerSourceContentUriByAttachmentContentUri.value = + photoPickerContentUriByAttachmentContentUri.toPersistentMap() + } + + private fun deleteTemporaryAttachment(contentUri: String) { + boundScope?.launch(defaultDispatcher) { + conversationAttachmentRepository + .deleteTemporaryAttachment(contentUri = contentUri) + .collect() + } } private companion object { diff --git a/src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationMediaPickerEffects.kt b/src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationMediaPickerEffects.kt deleted file mode 100644 index 13b33c03..00000000 --- a/src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationMediaPickerEffects.kt +++ /dev/null @@ -1,35 +0,0 @@ -package com.android.messaging.ui.conversation.v2.mediapicker - -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.SheetState -import androidx.compose.material3.SheetValue -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import com.android.messaging.data.media.model.ConversationMediaItem - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -internal fun HandlePendingGallerySelectionEffect( - pendingSelectedMediaItem: ConversationMediaItem?, - sheetState: SheetState, - onGalleryMediaConfirmed: (List) -> Unit, - onShowReview: (String) -> Unit, - onSelectionHandled: () -> Unit, -) { - LaunchedEffect(pendingSelectedMediaItem) { - val mediaItem = pendingSelectedMediaItem ?: return@LaunchedEffect - - val shouldExpandSheet = sheetState.currentValue == SheetValue.Expanded || - sheetState.targetValue == SheetValue.Expanded - - if (shouldExpandSheet) { - sheetState.partialExpand() - } - - onGalleryMediaConfirmed( - listOf(mediaItem), - ) - onShowReview(mediaItem.contentUri) - onSelectionHandled() - } -} diff --git a/src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationMediaPickerOverlay.kt b/src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationMediaPickerOverlay.kt index 9a3373e6..04fae0ba 100644 --- a/src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationMediaPickerOverlay.kt +++ b/src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationMediaPickerOverlay.kt @@ -1,9 +1,11 @@ package com.android.messaging.ui.conversation.v2.mediapicker import android.Manifest +import android.os.Build import androidx.activity.compose.BackHandler import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts +import androidx.annotation.RequiresExtension import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize @@ -15,19 +17,18 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.testTag -import com.android.messaging.data.media.model.ConversationMediaItem import com.android.messaging.ui.conversation.v2.CONVERSATION_MEDIA_PICKER_OVERLAY_TEST_TAG import com.android.messaging.ui.conversation.v2.composer.model.ComposerAttachmentUiModel import com.android.messaging.ui.conversation.v2.mediapicker.model.ConversationCapturedMedia -import com.android.messaging.ui.conversation.v2.mediapicker.model.ConversationMediaPickerUiState import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.ImmutableMap +@RequiresExtension(extension = Build.VERSION_CODES.UPSIDE_DOWN_CAKE, version = 15) @OptIn(ExperimentalLayoutApi::class) @Composable internal fun ConversationMediaPickerOverlay( modifier: Modifier = Modifier, state: ConversationMediaPickerState, - mediaPickerUiState: ConversationMediaPickerUiState, attachments: ImmutableList, conversationTitle: String?, isSendActionEnabled: Boolean, @@ -35,8 +36,9 @@ internal fun ConversationMediaPickerOverlay( onAttachmentPreviewClick: (ComposerAttachmentUiModel.Resolved.VisualMedia) -> Unit, onAttachmentCaptionChange: (String, String) -> Unit, onAttachmentRemove: (String) -> Unit, - onGalleryMediaConfirmed: (List) -> Unit, - onGalleryVisibilityChanged: (Boolean) -> Unit, + photoPickerSourceContentUriByAttachmentContentUri: ImmutableMap, + onPhotoPickerMediaSelected: (List) -> Unit, + onPhotoPickerMediaDeselected: (List) -> Unit, onCapturedMediaReady: (ConversationCapturedMedia) -> Unit, onSendClick: () -> Unit, ) { @@ -59,20 +61,6 @@ internal fun ConversationMediaPickerOverlay( permissionState.cameraPermissionGranted = isGranted } - val galleryPermissionLauncher = rememberLauncherForActivityResult( - contract = ActivityResultContracts.RequestMultiplePermissions(), - ) { permissionResults -> - permissionState.galleryPermissionGranted = permissionResults.values.all { isGranted -> - isGranted - } - } - - HandleConversationMediaPickerGalleryVisibilityEffect( - state = state, - galleryPermissionGranted = permissionState.galleryPermissionGranted, - onGalleryVisibilityChanged = onGalleryVisibilityChanged, - ) - HandleConversationMediaPickerVisibilityEffect( state = state, isImeVisible = isImeVisible, @@ -90,42 +78,33 @@ internal fun ConversationMediaPickerOverlay( state.close() } - if (!state.isOpen) { - return + if (state.isOpen) { + ConversationMediaPicker( + modifier = modifier + .fillMaxSize() + .testTag(CONVERSATION_MEDIA_PICKER_OVERLAY_TEST_TAG), + attachments = attachments, + conversationTitle = conversationTitle, + isSendActionEnabled = isSendActionEnabled, + state = state, + cameraPermissionGranted = permissionState.cameraPermissionGranted, + audioPermissionGranted = permissionState.audioPermissionGranted, + onClose = state::close, + onAttachmentPreviewClick = onAttachmentPreviewClick, + onAttachmentCaptionChange = onAttachmentCaptionChange, + onAttachmentRemove = onAttachmentRemove, + photoPickerSourceContentUriByAttachmentContentUri = + photoPickerSourceContentUriByAttachmentContentUri, + onPhotoPickerMediaSelected = onPhotoPickerMediaSelected, + onPhotoPickerMediaDeselected = onPhotoPickerMediaDeselected, + onRequestAudioPermission = { + audioPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO) + }, + onRequestCameraPermission = { + cameraPermissionLauncher.launch(Manifest.permission.CAMERA) + }, + onCapturedMediaReady = onCapturedMediaReady, + onSendClick = onSendClick, + ) } - - ConversationMediaPicker( - modifier = modifier - .fillMaxSize() - .testTag(CONVERSATION_MEDIA_PICKER_OVERLAY_TEST_TAG), - uiState = mediaPickerUiState, - attachments = attachments, - conversationTitle = conversationTitle, - isSendActionEnabled = isSendActionEnabled, - state = state, - cameraPermissionGranted = permissionState.cameraPermissionGranted, - audioPermissionGranted = permissionState.audioPermissionGranted, - galleryPermissionGranted = permissionState.galleryPermissionGranted, - onClose = state::close, - onAttachmentPreviewClick = onAttachmentPreviewClick, - onAttachmentCaptionChange = onAttachmentCaptionChange, - onAttachmentRemove = onAttachmentRemove, - onGalleryMediaConfirmed = onGalleryMediaConfirmed, - onRequestAudioPermission = { - audioPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO) - }, - onRequestCameraPermission = { - cameraPermissionLauncher.launch(Manifest.permission.CAMERA) - }, - onRequestGalleryPermission = { - galleryPermissionLauncher.launch( - arrayOf( - Manifest.permission.READ_MEDIA_IMAGES, - Manifest.permission.READ_MEDIA_VIDEO, - ), - ) - }, - onCapturedMediaReady = onCapturedMediaReady, - onSendClick = onSendClick, - ) } diff --git a/src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationMediaPickerPermission.kt b/src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationMediaPickerPermission.kt index 9bb7fadf..f9e30317 100644 --- a/src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationMediaPickerPermission.kt +++ b/src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationMediaPickerPermission.kt @@ -3,7 +3,6 @@ package com.android.messaging.ui.conversation.v2.mediapicker import android.Manifest import android.content.Context import android.content.pm.PackageManager -import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.Stable @@ -24,12 +23,10 @@ internal class ConversationMediaPickerPermissionState( ) { var audioPermissionGranted by mutableStateOf(value = hasAudioPermission(context = context)) var cameraPermissionGranted by mutableStateOf(value = hasCameraPermission(context = context)) - var galleryPermissionGranted by mutableStateOf(value = hasGalleryPermissions(context = context)) fun refresh(context: Context) { audioPermissionGranted = hasAudioPermission(context = context) cameraPermissionGranted = hasCameraPermission(context = context) - galleryPermissionGranted = hasGalleryPermissions(context = context) } } @@ -54,20 +51,6 @@ internal fun RefreshConversationMediaPickerPermissionsEffect( } } -@OptIn(ExperimentalLayoutApi::class) -@Composable -internal fun HandleConversationMediaPickerGalleryVisibilityEffect( - state: ConversationMediaPickerState, - galleryPermissionGranted: Boolean, - onGalleryVisibilityChanged: (Boolean) -> Unit, -) { - LaunchedEffect(state.isOpen, galleryPermissionGranted) { - if (state.isOpen && galleryPermissionGranted) { - onGalleryVisibilityChanged(true) - } - } -} - @Composable internal fun HandleConversationMediaPickerVisibilityEffect( state: ConversationMediaPickerState, @@ -108,20 +91,6 @@ private fun hasAudioPermission(context: Context): Boolean { ) } -private fun hasGalleryPermissions(context: Context): Boolean { - val hasImagesPermission = isPermissionGranted( - context = context, - permission = Manifest.permission.READ_MEDIA_IMAGES, - ) - - val hasVideoPermission = isPermissionGranted( - context = context, - permission = Manifest.permission.READ_MEDIA_VIDEO, - ) - - return hasImagesPermission && hasVideoPermission -} - private fun isPermissionGranted( context: Context, permission: String, diff --git a/src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationMediaPickerScaffold.kt b/src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationMediaPickerScaffold.kt index c989ffa3..8b276f29 100644 --- a/src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationMediaPickerScaffold.kt +++ b/src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationMediaPickerScaffold.kt @@ -1,5 +1,7 @@ package com.android.messaging.ui.conversation.v2.mediapicker +import android.os.Build +import androidx.annotation.RequiresExtension import androidx.compose.animation.AnimatedContent import androidx.compose.animation.ContentTransform import androidx.compose.animation.core.Spring @@ -10,33 +12,18 @@ import androidx.compose.animation.fadeOut import androidx.compose.animation.scaleIn import androidx.compose.animation.scaleOut import androidx.compose.animation.togetherWith -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.BottomSheetScaffold import androidx.compose.material3.BottomSheetScaffoldState import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.android.messaging.data.media.model.ConversationMediaItem import com.android.messaging.ui.conversation.v2.composer.model.ComposerAttachmentUiModel import com.android.messaging.ui.conversation.v2.mediapicker.camera.ConversationCameraController -import com.android.messaging.ui.conversation.v2.mediapicker.component.capture.ConversationMediaCameraPreviewSurface -import com.android.messaging.ui.conversation.v2.mediapicker.component.gallery.ConversationGallerySheet import com.android.messaging.ui.conversation.v2.mediapicker.component.review.ConversationMediaReviewScene import com.android.messaging.ui.conversation.v2.mediapicker.model.ConversationCapturedMedia -import com.android.messaging.ui.conversation.v2.mediapicker.model.ConversationMediaPickerUiState import kotlinx.collections.immutable.ImmutableList - -private const val CAMERA_PREVIEW_HEIGHT_FRACTION = 2f / 3f -private val PICKER_GALLERY_SHEET_HEIGHT_REDUCTION = 16.dp +import kotlinx.collections.immutable.ImmutableMap private enum class ConversationMediaPickerOverlayMode { Capture, @@ -44,12 +31,13 @@ private enum class ConversationMediaPickerOverlayMode { } @OptIn(ExperimentalMaterial3Api::class) +@RequiresExtension(extension = Build.VERSION_CODES.UPSIDE_DOWN_CAKE, version = 15) @Composable internal fun ConversationMediaPickerScaffold( modifier: Modifier = Modifier, cameraController: ConversationCameraController, scaffoldState: BottomSheetScaffoldState, - uiState: ConversationMediaPickerUiState, + photoPickerSheetContent: @Composable () -> Unit, visualAttachments: ImmutableList, conversationTitle: String?, captureMode: ConversationCaptureMode, @@ -59,273 +47,162 @@ internal fun ConversationMediaPickerScaffold( isSendActionEnabled: Boolean, cameraPermissionGranted: Boolean, audioPermissionGranted: Boolean, - galleryPermissionGranted: Boolean, onClose: () -> Unit, onAttachmentPreviewClick: (ComposerAttachmentUiModel.Resolved.VisualMedia) -> Unit, onAttachmentCaptionChange: (String, String) -> Unit, onAttachmentRemove: (String) -> Unit, - onGalleryMediaClick: (ConversationMediaItem) -> Unit, + photoPickerSourceContentUriByAttachmentContentUri: ImmutableMap, onRequestAudioPermission: () -> Unit, onRequestCameraPermission: () -> Unit, - onRequestGalleryPermission: () -> Unit, onCapturedMediaReady: (ConversationCapturedMedia) -> Unit, onSendClick: () -> Unit, onShowReview: (String) -> Unit, onClearReview: () -> Unit, onCaptureModeChange: (ConversationCaptureMode) -> Unit, ) { - val overlayMode = when { - isReviewVisible -> ConversationMediaPickerOverlayMode.Review - else -> ConversationMediaPickerOverlayMode.Capture - } - - BoxWithConstraints( - modifier = modifier - .fillMaxSize(), - ) { - val previewHeight = maxHeight * CAMERA_PREVIEW_HEIGHT_FRACTION - val defaultSheetPeekHeight = maxHeight - previewHeight - - val sheetPeekHeight = when { - defaultSheetPeekHeight > PICKER_GALLERY_SHEET_HEIGHT_REDUCTION -> { - defaultSheetPeekHeight - PICKER_GALLERY_SHEET_HEIGHT_REDUCTION - } - - else -> defaultSheetPeekHeight - } - - AnimatedContent( - modifier = Modifier - .fillMaxSize(), - targetState = overlayMode, - transitionSpec = { - pickerOverlayTransition() - }, - label = "pickerOverlayMode", - ) { currentOverlayMode -> - when (currentOverlayMode) { - ConversationMediaPickerOverlayMode.Capture -> { - ConversationMediaPickerCaptureScene( - cameraController = cameraController, - scaffoldState = scaffoldState, - cameraPermissionGranted = cameraPermissionGranted, - onRequestCameraPermission = onRequestCameraPermission, - uiState = uiState, - galleryPermissionGranted = galleryPermissionGranted, - onGalleryMediaClick = onGalleryMediaClick, - onRequestGalleryPermission = onRequestGalleryPermission, - sheetPeekHeight = sheetPeekHeight, - audioPermissionGranted = audioPermissionGranted, - captureMode = captureMode, - onClose = onClose, - onRequestAudioPermission = onRequestAudioPermission, - onShowReview = onShowReview, - onCapturedMediaReady = onCapturedMediaReady, - onCaptureModeChange = onCaptureModeChange, - ) - } - - ConversationMediaPickerOverlayMode.Review -> { - ConversationMediaPickerReviewScene( - scaffoldState = scaffoldState, - uiState = uiState, - galleryPermissionGranted = galleryPermissionGranted, - onGalleryMediaClick = onGalleryMediaClick, - onRequestGalleryPermission = onRequestGalleryPermission, - sheetPeekHeight = sheetPeekHeight, - attachments = visualAttachments, - conversationTitle = conversationTitle, - initiallyReviewedContentUri = reviewContentUri, - reviewRequestSequence = reviewRequestSequence, - isSendActionEnabled = isSendActionEnabled, - onAttachmentPreviewClick = onAttachmentPreviewClick, - onCaptionChange = onAttachmentCaptionChange, - onAttachmentRemove = onAttachmentRemove, - onAddMoreClick = onClearReview, - onClearReview = onClearReview, - onCloseClick = onClose, - onSendClick = { - onSendClick() - onClose() - }, - ) - } - } - } - } -} - -@Composable -@OptIn(ExperimentalMaterial3Api::class) -private fun ConversationMediaPickerReviewScene( - scaffoldState: BottomSheetScaffoldState, - uiState: ConversationMediaPickerUiState, - galleryPermissionGranted: Boolean, - onGalleryMediaClick: (ConversationMediaItem) -> Unit, - onRequestGalleryPermission: () -> Unit, - sheetPeekHeight: Dp, - attachments: ImmutableList, - conversationTitle: String?, - initiallyReviewedContentUri: String?, - reviewRequestSequence: Int, - isSendActionEnabled: Boolean, - onAttachmentPreviewClick: (ComposerAttachmentUiModel.Resolved.VisualMedia) -> Unit, - onCaptionChange: (String, String) -> Unit, - onAttachmentRemove: (String) -> Unit, - onAddMoreClick: () -> Unit, - onClearReview: () -> Unit, - onCloseClick: () -> Unit, - onSendClick: () -> Unit, -) { - BottomSheetScaffold( - modifier = Modifier - .fillMaxSize(), + ConversationMediaPickerSheetScaffold( + modifier = modifier, scaffoldState = scaffoldState, - sheetContainerColor = MaterialTheme.colorScheme.surfaceContainerHighest.copy(alpha = 0.98f), - sheetContentColor = MaterialTheme.colorScheme.onSurface, - sheetShape = RoundedCornerShape( - topStart = 28.dp, - topEnd = 28.dp, - ), - containerColor = Color.Transparent, - sheetDragHandle = null, - sheetPeekHeight = sheetPeekHeight, - sheetContent = { - ConversationGallerySheet( - uiState = uiState, - galleryPermissionGranted = galleryPermissionGranted, - onMediaClick = onGalleryMediaClick, - onRequestGalleryPermission = onRequestGalleryPermission, - ) - }, + photoPickerSheetContent = photoPickerSheetContent, ) { innerPadding -> - ConversationMediaReviewScene( + ConversationMediaPickerOverlayHost( modifier = Modifier.fillMaxSize(), + cameraController = cameraController, contentPadding = innerPadding, - attachments = attachments, + visualAttachments = visualAttachments, conversationTitle = conversationTitle, - initiallyReviewedContentUri = initiallyReviewedContentUri, + captureMode = captureMode, + reviewContentUri = reviewContentUri, reviewRequestSequence = reviewRequestSequence, + isReviewVisible = isReviewVisible, isSendActionEnabled = isSendActionEnabled, + cameraPermissionGranted = cameraPermissionGranted, + audioPermissionGranted = audioPermissionGranted, + onClose = onClose, onAttachmentPreviewClick = onAttachmentPreviewClick, - onCaptionChange = onCaptionChange, + onAttachmentCaptionChange = onAttachmentCaptionChange, onAttachmentRemove = onAttachmentRemove, - onAddMoreClick = onAddMoreClick, - onClearReview = onClearReview, - onCloseClick = onCloseClick, + photoPickerSourceContentUriByAttachmentContentUri = + photoPickerSourceContentUriByAttachmentContentUri, + onRequestAudioPermission = onRequestAudioPermission, + onRequestCameraPermission = onRequestCameraPermission, + onCapturedMediaReady = onCapturedMediaReady, onSendClick = onSendClick, + onShowReview = onShowReview, + onClearReview = onClearReview, + onCaptureModeChange = onCaptureModeChange, ) } } -@Composable @OptIn(ExperimentalMaterial3Api::class) -private fun ConversationMediaPickerCaptureScene( +@Composable +private fun ConversationMediaPickerOverlayHost( + modifier: Modifier = Modifier, cameraController: ConversationCameraController, - scaffoldState: BottomSheetScaffoldState, + contentPadding: PaddingValues, + visualAttachments: ImmutableList, + conversationTitle: String?, + captureMode: ConversationCaptureMode, + reviewContentUri: String?, + reviewRequestSequence: Int, + isReviewVisible: Boolean, + isSendActionEnabled: Boolean, cameraPermissionGranted: Boolean, - onRequestCameraPermission: () -> Unit, - uiState: ConversationMediaPickerUiState, - galleryPermissionGranted: Boolean, - onGalleryMediaClick: (ConversationMediaItem) -> Unit, - onRequestGalleryPermission: () -> Unit, - sheetPeekHeight: Dp, audioPermissionGranted: Boolean, - captureMode: ConversationCaptureMode, onClose: () -> Unit, + onAttachmentPreviewClick: (ComposerAttachmentUiModel.Resolved.VisualMedia) -> Unit, + onAttachmentCaptionChange: (String, String) -> Unit, + onAttachmentRemove: (String) -> Unit, + photoPickerSourceContentUriByAttachmentContentUri: ImmutableMap, onRequestAudioPermission: () -> Unit, - onShowReview: (String) -> Unit, + onRequestCameraPermission: () -> Unit, onCapturedMediaReady: (ConversationCapturedMedia) -> Unit, + onSendClick: () -> Unit, + onShowReview: (String) -> Unit, + onClearReview: () -> Unit, onCaptureModeChange: (ConversationCaptureMode) -> Unit, ) { - Box( - modifier = Modifier + AnimatedContent( + modifier = modifier .fillMaxSize(), - ) { - ConversationMediaCameraPreviewRoute( - modifier = Modifier - .fillMaxSize(), - cameraController = cameraController, - cameraPermissionGranted = cameraPermissionGranted, - onRequestCameraPermission = onRequestCameraPermission, - ) + targetState = resolveOverlayMode(isReviewVisible = isReviewVisible), + transitionSpec = { + pickerOverlayTransition() + }, + label = "pickerOverlayMode", + ) { currentOverlayMode -> + when (currentOverlayMode) { + ConversationMediaPickerOverlayMode.Capture -> { + ConversationMediaPickerCaptureScene( + cameraController = cameraController, + contentPadding = contentPadding, + captureMode = captureMode, + cameraPermissionGranted = cameraPermissionGranted, + audioPermissionGranted = audioPermissionGranted, + onClose = onClose, + onRequestAudioPermission = onRequestAudioPermission, + onRequestCameraPermission = onRequestCameraPermission, + onCapturedMediaReady = onCapturedMediaReady, + onShowReview = onShowReview, + onCaptureModeChange = onCaptureModeChange, + ) + } - BottomSheetScaffold( - modifier = Modifier - .fillMaxSize(), - scaffoldState = scaffoldState, - sheetContainerColor = MaterialTheme.colorScheme.surfaceContainerHigh, - sheetContentColor = MaterialTheme.colorScheme.onSurface, - sheetShape = RoundedCornerShape( - topStart = 28.dp, - topEnd = 28.dp, - ), - containerColor = Color.Transparent, - sheetDragHandle = null, - sheetPeekHeight = sheetPeekHeight, - sheetContent = { - ConversationGallerySheet( - uiState = uiState, - galleryPermissionGranted = galleryPermissionGranted, - onMediaClick = onGalleryMediaClick, - onRequestGalleryPermission = onRequestGalleryPermission, + ConversationMediaPickerOverlayMode.Review -> { + ConversationMediaReviewScene( + modifier = Modifier.fillMaxSize(), + contentPadding = contentPadding, + attachments = visualAttachments, + conversationTitle = conversationTitle, + initiallyReviewedContentUri = reviewContentUri, + reviewRequestSequence = reviewRequestSequence, + isSendActionEnabled = isSendActionEnabled, + photoPickerSourceContentUriByAttachmentContentUri = + photoPickerSourceContentUriByAttachmentContentUri, + onAttachmentPreviewClick = onAttachmentPreviewClick, + onCaptionChange = onAttachmentCaptionChange, + onAttachmentRemove = onAttachmentRemove, + onAddMoreClick = onClearReview, + onClearReview = onClearReview, + onCloseClick = onClose, + onSendClick = { + onSendClick() + onClose() + }, ) - }, - ) { innerPadding -> - ConversationMediaCaptureRoute( - modifier = Modifier - .fillMaxSize() - .padding(paddingValues = innerPadding), - cameraController = cameraController, - audioPermissionGranted = audioPermissionGranted, - captureMode = captureMode, - onClose = onClose, - onRequestAudioPermission = onRequestAudioPermission, - onShowReview = onShowReview, - onCapturedMediaReady = onCapturedMediaReady, - onCaptureModeChange = onCaptureModeChange, - ) + } } } } -@Composable -private fun ConversationMediaCameraPreviewRoute( - modifier: Modifier = Modifier, - cameraController: ConversationCameraController, - cameraPermissionGranted: Boolean, - onRequestCameraPermission: () -> Unit, -) { - val surfaceRequest = cameraController.surfaceRequest.collectAsStateWithLifecycle() - - ConversationMediaCameraPreviewSurface( - modifier = modifier, - cameraPermissionGranted = cameraPermissionGranted, - surfaceRequest = surfaceRequest.value, - onRequestCameraPermission = onRequestCameraPermission, - ) +private fun resolveOverlayMode(isReviewVisible: Boolean): ConversationMediaPickerOverlayMode { + return when { + isReviewVisible -> ConversationMediaPickerOverlayMode.Review + else -> ConversationMediaPickerOverlayMode.Capture + } } private fun pickerOverlayTransition(): ContentTransform { - return ( - fadeIn( - animationSpec = tween( - durationMillis = 180, - delayMillis = 40, - ), - ) + scaleIn( - initialScale = 0.98f, - animationSpec = spring( - dampingRatio = Spring.DampingRatioNoBouncy, - stiffness = Spring.StiffnessMediumLow, - ), - ) - ).togetherWith( - fadeOut( - animationSpec = tween(durationMillis = 100), - ) + scaleOut( - targetScale = 0.985f, - animationSpec = tween(durationMillis = 100), + val enterTransition = fadeIn( + animationSpec = tween( + durationMillis = 180, + delayMillis = 40, + ), + ) + scaleIn( + initialScale = 0.95f, + animationSpec = spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = Spring.StiffnessMediumLow, ), ) + + val exitTransition = fadeOut( + animationSpec = tween(durationMillis = 100), + ) + scaleOut( + targetScale = 0.985f, + animationSpec = tween(durationMillis = 100), + ) + + return enterTransition.togetherWith(exitTransition) } diff --git a/src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationMediaPickerSheetScaffold.kt b/src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationMediaPickerSheetScaffold.kt new file mode 100644 index 00000000..7ac12a9b --- /dev/null +++ b/src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationMediaPickerSheetScaffold.kt @@ -0,0 +1,92 @@ +package com.android.messaging.ui.conversation.v2.mediapicker + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.BottomSheetDefaults +import androidx.compose.material3.BottomSheetScaffold +import androidx.compose.material3.BottomSheetScaffoldState +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +private const val CAMERA_PREVIEW_HEIGHT_FRACTION = 2f / 3f +private val PICKER_GALLERY_SHEET_HEIGHT_REDUCTION = 16.dp +private val PHOTO_PICKER_SHEET_TOP_CORNER_RADIUS = 28.dp + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun ConversationMediaPickerSheetScaffold( + modifier: Modifier = Modifier, + scaffoldState: BottomSheetScaffoldState, + photoPickerSheetContent: @Composable () -> Unit, + content: @Composable (PaddingValues) -> Unit, +) { + BoxWithConstraints( + modifier = modifier + .fillMaxSize(), + ) { + BottomSheetScaffold( + modifier = Modifier + .fillMaxSize(), + scaffoldState = scaffoldState, + sheetContainerColor = Color.Transparent, + sheetContentColor = MaterialTheme.colorScheme.onSurface, + sheetShape = RectangleShape, + containerColor = Color.Transparent, + sheetDragHandle = { + ConversationPhotoPickerSheetHeader() + }, + sheetPeekHeight = calculatePhotoPickerSheetPeekHeight(maxHeight = maxHeight), + sheetContent = { + photoPickerSheetContent() + }, + ) { innerPadding -> + content(innerPadding) + } + } +} + +private fun calculatePhotoPickerSheetPeekHeight(maxHeight: Dp): Dp { + val previewHeight = maxHeight * CAMERA_PREVIEW_HEIGHT_FRACTION + val defaultSheetPeekHeight = maxHeight - previewHeight + + return when { + defaultSheetPeekHeight > PICKER_GALLERY_SHEET_HEIGHT_REDUCTION -> { + defaultSheetPeekHeight - PICKER_GALLERY_SHEET_HEIGHT_REDUCTION + } + + else -> defaultSheetPeekHeight + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ConversationPhotoPickerSheetHeader( + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier + .fillMaxWidth() + .background( + color = MaterialTheme.colorScheme.surface, + shape = RoundedCornerShape( + topStart = PHOTO_PICKER_SHEET_TOP_CORNER_RADIUS, + topEnd = PHOTO_PICKER_SHEET_TOP_CORNER_RADIUS, + ), + ), + contentAlignment = Alignment.Center, + ) { + BottomSheetDefaults.DragHandle() + } +} diff --git a/src/com/android/messaging/ui/conversation/v2/mediapicker/component/gallery/ConversationMediaPickerGallery.kt b/src/com/android/messaging/ui/conversation/v2/mediapicker/component/gallery/ConversationMediaPickerGallery.kt deleted file mode 100644 index 44cd4429..00000000 --- a/src/com/android/messaging/ui/conversation/v2/mediapicker/component/gallery/ConversationMediaPickerGallery.kt +++ /dev/null @@ -1,235 +0,0 @@ -package com.android.messaging.ui.conversation.v2.mediapicker.component.gallery - -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.aspectRatio -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.navigationBarsPadding -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.lazy.grid.GridCells -import androidx.compose.foundation.lazy.grid.GridItemSpan -import androidx.compose.foundation.lazy.grid.LazyGridScope -import androidx.compose.foundation.lazy.grid.LazyVerticalGrid -import androidx.compose.foundation.lazy.grid.items -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.PhotoLibrary -import androidx.compose.material.icons.rounded.PlayArrow -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.IntSize -import androidx.compose.ui.unit.dp -import com.android.messaging.R -import com.android.messaging.data.media.model.ConversationMediaItem -import com.android.messaging.data.media.model.ConversationMediaType -import com.android.messaging.ui.conversation.v2.mediapicker.component.ConversationMediaThumbnail -import com.android.messaging.ui.conversation.v2.mediapicker.component.PermissionFallback -import com.android.messaging.ui.conversation.v2.mediapicker.model.ConversationMediaPickerUiState - -private val GALLERY_GRID_SPACING = 8.dp -private val GALLERY_ITEM_CORNER_RADIUS = 20.dp -private const val GALLERY_ITEM_SIZE_PX = 384 - -@Composable -internal fun ConversationGallerySheet( - uiState: ConversationMediaPickerUiState, - galleryPermissionGranted: Boolean, - onMediaClick: (ConversationMediaItem) -> Unit, - onRequestGalleryPermission: () -> Unit, -) { - LazyVerticalGrid( - modifier = Modifier.navigationBarsPadding(), - columns = GridCells.Fixed(3), - contentPadding = PaddingValues( - start = 16.dp, - top = 12.dp, - end = 16.dp, - bottom = 20.dp, - ), - horizontalArrangement = Arrangement.spacedBy(GALLERY_GRID_SPACING), - verticalArrangement = Arrangement.spacedBy(GALLERY_GRID_SPACING), - ) { - item( - span = { - GridItemSpan(maxLineSpan) - }, - ) { - GallerySheetDragHandle() - } - - when { - !galleryPermissionGranted -> { - galleryPermissionItem( - onRequestGalleryPermission = onRequestGalleryPermission, - ) - } - - uiState.isLoadingGallery -> { - galleryLoadingItem() - } - - else -> { - galleryItems( - items = uiState.galleryItems, - onMediaClick = onMediaClick, - ) - } - } - } -} - -private fun LazyGridScope.galleryPermissionItem( - onRequestGalleryPermission: () -> Unit, -) { - item( - span = { - GridItemSpan(maxLineSpan) - }, - ) { - PermissionFallback( - icon = { - Icon( - imageVector = Icons.Rounded.PhotoLibrary, - contentDescription = null, - ) - }, - message = stringResource( - id = R.string.conversation_media_picker_gallery_permission_message, - ), - actionLabel = stringResource( - id = R.string.conversation_media_picker_allow_gallery, - ), - onActionClick = onRequestGalleryPermission, - ) - } -} - -private fun LazyGridScope.galleryLoadingItem() { - item( - span = { - GridItemSpan(maxLineSpan) - }, - ) { - Box( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 24.dp), - contentAlignment = Alignment.Center, - ) { - CircularProgressIndicator() - } - } -} - -private fun LazyGridScope.galleryItems( - items: List, - onMediaClick: (ConversationMediaItem) -> Unit, -) { - items( - items = items, - key = { item -> item.mediaId }, - ) { item -> - GalleryGridItem( - item = item, - onClick = { - onMediaClick(item) - }, - ) - } -} - -@Composable -private fun GallerySheetDragHandle( - modifier: Modifier = Modifier, -) { - Box( - modifier = modifier - .fillMaxWidth() - .padding(bottom = 4.dp), - contentAlignment = Alignment.Center, - ) { - Box( - modifier = Modifier - .size( - width = 32.dp, - height = 4.dp, - ) - .clip(CircleShape) - .background( - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f), - ), - ) - } -} - -@Composable -private fun GalleryGridItem( - item: ConversationMediaItem, - onClick: () -> Unit, -) { - val thumbnailSize = IntSize( - width = GALLERY_ITEM_SIZE_PX, - height = GALLERY_ITEM_SIZE_PX, - ) - - Surface( - modifier = Modifier - .fillMaxWidth() - .aspectRatio(1f) - .clip(RoundedCornerShape(GALLERY_ITEM_CORNER_RADIUS)) - .clickable(onClick = onClick), - shape = RoundedCornerShape(GALLERY_ITEM_CORNER_RADIUS), - color = MaterialTheme.colorScheme.surfaceContainerHighest, - ) { - Box { - ConversationMediaThumbnail( - modifier = Modifier.fillMaxSize(), - contentUri = item.contentUri, - contentType = item.contentType, - size = thumbnailSize, - ) - - if (item.mediaType == ConversationMediaType.Video) { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center, - ) { - Icon( - imageVector = Icons.Rounded.PlayArrow, - contentDescription = null, - tint = MaterialTheme.colorScheme.onPrimary, - ) - } - } - } - } -} - - -private fun previewMediaItem( - id: String, - type: ConversationMediaType, -): ConversationMediaItem { - return ConversationMediaItem( - mediaId = id, - contentUri = "content://media/external/images/media/$id", - contentType = if (type == ConversationMediaType.Image) "image/jpeg" else "video/mp4", - mediaType = type, - width = 1080, - height = 1920, - durationMillis = if (type == ConversationMediaType.Video) 30000L else null, - ) -} diff --git a/src/com/android/messaging/ui/conversation/v2/mediapicker/component/review/ConversationMediaPickerReview.kt b/src/com/android/messaging/ui/conversation/v2/mediapicker/component/review/ConversationMediaPickerReview.kt index 0114e829..4178e539 100644 --- a/src/com/android/messaging/ui/conversation/v2/mediapicker/component/review/ConversationMediaPickerReview.kt +++ b/src/com/android/messaging/ui/conversation/v2/mediapicker/component/review/ConversationMediaPickerReview.kt @@ -44,6 +44,8 @@ import com.android.messaging.ui.conversation.v2.composer.ui.ConversationSendActi import com.android.messaging.ui.conversation.v2.composer.ui.ConversationSendActionButtonMode import com.android.messaging.ui.conversation.v2.mediapicker.component.PickerOverlayIconButton import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.ImmutableMap +import kotlinx.collections.immutable.persistentMapOf private const val PICKER_REVIEW_PAGE_ASPECT_RATIO = 0.8f private const val PICKER_REVIEW_PAGE_MAX_HEIGHT_FRACTION = 0.95f @@ -58,6 +60,8 @@ internal fun ConversationMediaReviewScene( initiallyReviewedContentUri: String?, reviewRequestSequence: Int, isSendActionEnabled: Boolean, + photoPickerSourceContentUriByAttachmentContentUri: ImmutableMap = + persistentMapOf(), onAttachmentPreviewClick: (ComposerAttachmentUiModel.Resolved.VisualMedia) -> Unit, onCaptionChange: (String, String) -> Unit, onAttachmentRemove: (String) -> Unit, @@ -74,6 +78,8 @@ internal fun ConversationMediaReviewScene( attachments = attachments, initiallyReviewedContentUri = initiallyReviewedContentUri, reviewRequestSequence = reviewRequestSequence, + photoPickerSourceContentUriByAttachmentContentUri = + photoPickerSourceContentUriByAttachmentContentUri, ) val imeBottomPadding = WindowInsets.ime.asPaddingValues().calculateBottomPadding() diff --git a/src/com/android/messaging/ui/conversation/v2/mediapicker/component/review/ConversationMediaReviewPagerState.kt b/src/com/android/messaging/ui/conversation/v2/mediapicker/component/review/ConversationMediaReviewPagerState.kt index 43e13e02..63f4e1ea 100644 --- a/src/com/android/messaging/ui/conversation/v2/mediapicker/component/review/ConversationMediaReviewPagerState.kt +++ b/src/com/android/messaging/ui/conversation/v2/mediapicker/component/review/ConversationMediaReviewPagerState.kt @@ -8,6 +8,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember import com.android.messaging.ui.conversation.v2.composer.model.ComposerAttachmentUiModel import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.ImmutableMap import kotlinx.collections.immutable.toImmutableList internal data class ConversationMediaReviewPagerState( @@ -22,6 +23,7 @@ internal fun rememberConversationMediaReviewPagerState( attachments: ImmutableList, initiallyReviewedContentUri: String?, reviewRequestSequence: Int, + photoPickerSourceContentUriByAttachmentContentUri: ImmutableMap, ): ConversationMediaReviewPagerState { val attachmentContentUris = remember(attachments) { attachments @@ -33,6 +35,8 @@ internal fun rememberConversationMediaReviewPagerState( val initiallyReviewedPage = resolveInitialReviewPage( attachments = attachments, initiallyReviewedContentUri = initiallyReviewedContentUri, + photoPickerSourceContentUriByAttachmentContentUri = + photoPickerSourceContentUriByAttachmentContentUri, ) val pagerState = rememberPagerState( @@ -53,6 +57,7 @@ internal fun rememberConversationMediaReviewPagerState( LaunchedEffect( attachmentContentUris, initiallyReviewedContentUri, + photoPickerSourceContentUriByAttachmentContentUri, reviewRequestSequence, settledReviewPage, ) { @@ -61,6 +66,8 @@ internal fun rememberConversationMediaReviewPagerState( attachments = attachments, initiallyReviewedContentUri = initiallyReviewedContentUri, reviewRequestSequence = reviewRequestSequence, + photoPickerSourceContentUriByAttachmentContentUri = + photoPickerSourceContentUriByAttachmentContentUri, pagerState = pagerState, ) } @@ -88,6 +95,7 @@ private class ConversationMediaReviewPagerCoordinator( attachments: List, initiallyReviewedContentUri: String?, reviewRequestSequence: Int, + photoPickerSourceContentUriByAttachmentContentUri: Map, pagerState: PagerState, ) { if (reviewRequestSequence != latestReviewRequestSequence) { @@ -97,7 +105,10 @@ private class ConversationMediaReviewPagerCoordinator( val requestedAttachmentPage = resolveReviewedAttachmentPage( attachmentContentUris = attachmentContentUris, + attachments = attachments, requestedReviewContentUri = pendingRequestedReviewContentUri, + photoPickerSourceContentUriByAttachmentContentUri = + photoPickerSourceContentUriByAttachmentContentUri, ) val targetPage = requestedAttachmentPage ?: clampAttachmentPage( @@ -127,20 +138,39 @@ private class ConversationMediaReviewPagerCoordinator( private fun resolveReviewedAttachmentPage( attachmentContentUris: List, + attachments: List, requestedReviewContentUri: String?, + photoPickerSourceContentUriByAttachmentContentUri: Map, ): Int? { - return requestedReviewContentUri - ?.let(attachmentContentUris::indexOf) - ?.takeIf { it >= 0 } + if (requestedReviewContentUri == null) { + return null + } + + return attachmentContentUris + .indexOf(element = requestedReviewContentUri) + .takeIf { it >= 0 } + ?: attachments.indexOfFirst { attachment -> + photoPickerSourceContentUriByAttachmentContentUri[attachment.contentUri] == + requestedReviewContentUri + }.takeIf { it >= 0 } } } private fun resolveInitialReviewPage( attachments: List, initiallyReviewedContentUri: String?, + photoPickerSourceContentUriByAttachmentContentUri: Map, ): Int { + if (initiallyReviewedContentUri == null) { + return attachments.lastIndex + } + return attachments - .indexOfFirst { it.contentUri == initiallyReviewedContentUri } + .indexOfFirst { attachment -> + attachment.contentUri == initiallyReviewedContentUri || + photoPickerSourceContentUriByAttachmentContentUri[attachment.contentUri] == + initiallyReviewedContentUri + } .takeIf { it >= 0 } ?: attachments.lastIndex } diff --git a/src/com/android/messaging/ui/conversation/v2/mediapicker/model/ConversationMediaPickerUiState.kt b/src/com/android/messaging/ui/conversation/v2/mediapicker/model/ConversationMediaPickerUiState.kt deleted file mode 100644 index 83564182..00000000 --- a/src/com/android/messaging/ui/conversation/v2/mediapicker/model/ConversationMediaPickerUiState.kt +++ /dev/null @@ -1,12 +0,0 @@ -package com.android.messaging.ui.conversation.v2.mediapicker.model - -import androidx.compose.runtime.Immutable -import com.android.messaging.data.media.model.ConversationMediaItem -import kotlinx.collections.immutable.ImmutableList -import kotlinx.collections.immutable.persistentListOf - -@Immutable -internal data class ConversationMediaPickerUiState( - val galleryItems: ImmutableList = persistentListOf(), - val isLoadingGallery: Boolean = false, -) diff --git a/src/com/android/messaging/ui/conversation/v2/screen/ConversationScreen.kt b/src/com/android/messaging/ui/conversation/v2/screen/ConversationScreen.kt index 5e0c8514..f8a3df05 100644 --- a/src/com/android/messaging/ui/conversation/v2/screen/ConversationScreen.kt +++ b/src/com/android/messaging/ui/conversation/v2/screen/ConversationScreen.kt @@ -280,7 +280,6 @@ internal fun ConversationScreen( modifier = Modifier .fillMaxSize(), state = mediaPickerState, - mediaPickerUiState = mediaPickerOverlayUiState.mediaPicker, attachments = mediaPickerOverlayUiState.attachments, conversationTitle = mediaPickerOverlayUiState.conversationTitle, isSendActionEnabled = mediaPickerOverlayUiState.isSendActionEnabled, @@ -290,8 +289,10 @@ internal fun ConversationScreen( }, onAttachmentCaptionChange = screenModel::onUpdateAttachmentCaption, onAttachmentRemove = screenModel::onRemoveResolvedAttachment, - onGalleryMediaConfirmed = screenModel::onGalleryMediaConfirmed, - onGalleryVisibilityChanged = screenModel::onGalleryVisibilityChanged, + photoPickerSourceContentUriByAttachmentContentUri = + mediaPickerOverlayUiState.photoPickerSourceContentUriByAttachmentContentUri, + onPhotoPickerMediaSelected = screenModel::onPhotoPickerMediaSelected, + onPhotoPickerMediaDeselected = screenModel::onPhotoPickerMediaDeselected, onCapturedMediaReady = screenModel::onCapturedMediaReady, onSendClick = screenModel::onSendClick, ) diff --git a/src/com/android/messaging/ui/conversation/v2/screen/ConversationViewModel.kt b/src/com/android/messaging/ui/conversation/v2/screen/ConversationViewModel.kt index 9e8c8ecc..fcf5f8ca 100644 --- a/src/com/android/messaging/ui/conversation/v2/screen/ConversationViewModel.kt +++ b/src/com/android/messaging/ui/conversation/v2/screen/ConversationViewModel.kt @@ -7,7 +7,6 @@ import androidx.lifecycle.viewModelScope import com.android.messaging.R import com.android.messaging.data.conversation.model.draft.ConversationDraft import com.android.messaging.data.conversation.repository.ConversationSubscriptionsRepository -import com.android.messaging.data.media.model.ConversationMediaItem import com.android.messaging.datamodel.MessagingContentProvider import com.android.messaging.di.core.DefaultDispatcher import com.android.messaging.domain.conversation.usecase.action.CreateDefaultSmsRoleRequest @@ -35,7 +34,6 @@ import com.android.messaging.ui.conversation.v2.screen.model.ConversationMessage import com.android.messaging.ui.conversation.v2.screen.model.ConversationScreenEffect import com.android.messaging.ui.conversation.v2.screen.model.ConversationScreenScaffoldUiState import dagger.hilt.android.lifecycle.HiltViewModel -import javax.inject.Inject import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.flow.Flow @@ -46,6 +44,7 @@ import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch +import javax.inject.Inject internal interface ConversationScreenModel { val effects: Flow @@ -83,7 +82,8 @@ internal interface ConversationScreenModel { fun onExternalUriClicked(uri: String) - fun onGalleryMediaConfirmed(mediaItems: List) + fun onPhotoPickerMediaSelected(contentUris: List) + fun onPhotoPickerMediaDeselected(contentUris: List) fun onContactCardPicked(contactUri: String?) fun onMessageTextChanged(text: String) fun onAudioRecordingStart() @@ -91,7 +91,6 @@ internal interface ConversationScreenModel { fun onAudioRecordingLock(): Boolean fun onAudioRecordingFinish() fun onAudioRecordingCancel() - fun onGalleryVisibilityChanged(isVisible: Boolean) fun onCapturedMediaReady(capturedMedia: ConversationCapturedMedia) fun onRemovePendingAttachment(pendingAttachmentId: String) fun onRemoveResolvedAttachment(contentUri: String) @@ -250,19 +249,20 @@ internal class ConversationViewModel @Inject constructor( override val mediaPickerOverlayUiState = combine( conversationMetadataDelegate.state, - conversationMediaPickerDelegate.state, composerUiState, - ) { metadataState, mediaPickerUiState, composerUiState -> + conversationMediaPickerDelegate.photoPickerSourceContentUriByAttachmentContentUri, + ) { metadataState, composerUiState, photoPickerSourceContentUriByAttachmentContentUri -> val conversationTitle = when (metadataState) { is ConversationMetadataUiState.Present -> metadataState.title else -> null } ConversationMediaPickerOverlayUiState( - mediaPicker = mediaPickerUiState, attachments = composerUiState.attachments, conversationTitle = conversationTitle, isSendActionEnabled = composerUiState.isSendEnabled, + photoPickerSourceContentUriByAttachmentContentUri = + photoPickerSourceContentUriByAttachmentContentUri, ) }.stateIn( scope = viewModelScope, @@ -270,10 +270,11 @@ internal class ConversationViewModel @Inject constructor( stopTimeoutMillis = STATEFLOW_STOP_TIMEOUT_MILLIS, ), initialValue = ConversationMediaPickerOverlayUiState( - mediaPicker = conversationMediaPickerDelegate.state.value, attachments = composerUiState.value.attachments, conversationTitle = null, isSendActionEnabled = composerUiState.value.isSendEnabled, + photoPickerSourceContentUriByAttachmentContentUri = conversationMediaPickerDelegate + .photoPickerSourceContentUriByAttachmentContentUri.value, ), ) @@ -498,8 +499,12 @@ internal class ConversationViewModel @Inject constructor( } } - override fun onGalleryMediaConfirmed(mediaItems: List) { - conversationMediaPickerDelegate.onGalleryMediaConfirmed(mediaItems = mediaItems) + override fun onPhotoPickerMediaSelected(contentUris: List) { + conversationMediaPickerDelegate.onPhotoPickerMediaSelected(contentUris = contentUris) + } + + override fun onPhotoPickerMediaDeselected(contentUris: List) { + conversationMediaPickerDelegate.onPhotoPickerMediaDeselected(contentUris = contentUris) } override fun onContactCardPicked(contactUri: String?) { @@ -552,10 +557,6 @@ internal class ConversationViewModel @Inject constructor( conversationAudioRecordingDelegate.cancelRecording() } - override fun onGalleryVisibilityChanged(isVisible: Boolean) { - conversationMediaPickerDelegate.onGalleryVisibilityChanged(isVisible = isVisible) - } - override fun onCapturedMediaReady(capturedMedia: ConversationCapturedMedia) { conversationMediaPickerDelegate.onCapturedMediaReady(capturedMedia = capturedMedia) } diff --git a/src/com/android/messaging/ui/conversation/v2/screen/model/ConversationMediaPickerOverlayUiState.kt b/src/com/android/messaging/ui/conversation/v2/screen/model/ConversationMediaPickerOverlayUiState.kt index 538f8535..8096d20c 100644 --- a/src/com/android/messaging/ui/conversation/v2/screen/model/ConversationMediaPickerOverlayUiState.kt +++ b/src/com/android/messaging/ui/conversation/v2/screen/model/ConversationMediaPickerOverlayUiState.kt @@ -2,14 +2,16 @@ package com.android.messaging.ui.conversation.v2.screen.model import androidx.compose.runtime.Immutable import com.android.messaging.ui.conversation.v2.composer.model.ComposerAttachmentUiModel -import com.android.messaging.ui.conversation.v2.mediapicker.model.ConversationMediaPickerUiState import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.ImmutableMap +import kotlinx.collections.immutable.persistentMapOf import kotlinx.collections.immutable.persistentListOf @Immutable internal data class ConversationMediaPickerOverlayUiState( - val mediaPicker: ConversationMediaPickerUiState = ConversationMediaPickerUiState(), val attachments: ImmutableList = persistentListOf(), val conversationTitle: String? = null, val isSendActionEnabled: Boolean = false, + val photoPickerSourceContentUriByAttachmentContentUri: ImmutableMap = + persistentMapOf(), ) From 404148dea139588be2ead71b4391cda62b52d18c Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Wed, 29 Apr 2026 22:30:44 +0300 Subject: [PATCH 77/99] Keep media review captions stable while editing --- .../review/ConversationMediaPickerReview.kt | 42 +++++++++++++++++-- 1 file changed, 39 insertions(+), 3 deletions(-) diff --git a/src/com/android/messaging/ui/conversation/v2/mediapicker/component/review/ConversationMediaPickerReview.kt b/src/com/android/messaging/ui/conversation/v2/mediapicker/component/review/ConversationMediaPickerReview.kt index 4178e539..0d82451b 100644 --- a/src/com/android/messaging/ui/conversation/v2/mediapicker/component/review/ConversationMediaPickerReview.kt +++ b/src/com/android/messaging/ui/conversation/v2/mediapicker/component/review/ConversationMediaPickerReview.kt @@ -25,6 +25,7 @@ import androidx.compose.material3.Text import androidx.compose.material3.TextField import androidx.compose.material3.TextFieldDefaults import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.SideEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -32,9 +33,12 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp @@ -290,16 +294,40 @@ private fun rememberLargestReviewPreviewSize( @Composable private fun ReviewCaptionTextField( modifier: Modifier = Modifier, + attachmentContentUri: String, captionText: String, onCaptionChange: (String) -> Unit, ) { val containerColor = MaterialTheme.colorScheme.surfaceContainerLowest.copy(alpha = 0.95f) + var isFocused by remember { + mutableStateOf(value = false) + } + var fieldValue by remember(attachmentContentUri) { + mutableStateOf( + value = captionText.toCaptionTextFieldValue(), + ) + } + + LaunchedEffect(attachmentContentUri, captionText) { + if (!isFocused && fieldValue.text != captionText) { + fieldValue = captionText.toCaptionTextFieldValue() + } + } TextField( modifier = modifier - .fillMaxWidth(), - value = captionText, - onValueChange = onCaptionChange, + .fillMaxWidth() + .onFocusChanged { focusState -> + isFocused = focusState.isFocused + }, + value = fieldValue, + onValueChange = { updatedFieldValue -> + val previousText = fieldValue.text + fieldValue = updatedFieldValue + if (updatedFieldValue.text != previousText) { + onCaptionChange(updatedFieldValue.text) + } + }, shape = RoundedCornerShape(28.dp), colors = TextFieldDefaults.colors( focusedContainerColor = containerColor, @@ -329,6 +357,13 @@ private fun ReviewCaptionTextField( ) } +private fun String.toCaptionTextFieldValue(): TextFieldValue { + return TextFieldValue( + text = this, + selection = TextRange(index = length), + ) +} + @Composable private fun ConversationMediaReviewBottomBar( attachment: ComposerAttachmentUiModel.Resolved.VisualMedia, @@ -345,6 +380,7 @@ private fun ConversationMediaReviewBottomBar( ) { ReviewCaptionTextField( modifier = Modifier.weight(weight = 1f), + attachmentContentUri = attachment.contentUri, captionText = attachment.captionText, onCaptionChange = { captionText -> onCaptionChange( From 8a7d6b654f193aa5cf2bf5b12042929d53c72901 Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Wed, 29 Apr 2026 22:31:04 +0300 Subject: [PATCH 78/99] Expose MMS indicator test tag in semantics --- .../conversation/v2/composer/ui/ConversationComposeBar.kt | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationComposeBar.kt b/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationComposeBar.kt index 291d62a6..75d8eccd 100644 --- a/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationComposeBar.kt +++ b/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationComposeBar.kt @@ -59,6 +59,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.stateDescription +import androidx.compose.ui.semantics.testTag import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.window.PopupProperties @@ -524,8 +525,9 @@ private fun MmsIndicator() { Text( modifier = Modifier .padding(end = 12.dp) - .clearAndSetSemantics {} - .testTag(CONVERSATION_MMS_INDICATOR_TEST_TAG), + .clearAndSetSemantics { + testTag = CONVERSATION_MMS_INDICATOR_TEST_TAG + }, text = stringResource(id = R.string.mms_text), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.tertiary, From 2078fa4732ebd18c45c90f293dc99002de1413fe Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Wed, 29 Apr 2026 23:34:18 +0300 Subject: [PATCH 79/99] Disable ForbiddenComment rule in Detekt --- config/detekt/detekt.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/config/detekt/detekt.yml b/config/detekt/detekt.yml index 0aff4317..f9da5159 100644 --- a/config/detekt/detekt.yml +++ b/config/detekt/detekt.yml @@ -15,11 +15,15 @@ naming: - Composable style: + ForbiddenComment: + active: false + MagicNumber: ignoreCompanionObjectPropertyDeclaration: true ignorePropertyDeclaration: true ignoreAnnotated: - Composable + UnusedPrivateFunction: ignoreAnnotated: - Preview From 702bbdae85d2401bd74ef19e8a564634a03f1d9b Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Thu, 30 Apr 2026 02:52:59 +0300 Subject: [PATCH 80/99] Adjust Detekt functions count rules to the reality --- config/detekt/detekt.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/config/detekt/detekt.yml b/config/detekt/detekt.yml index f9da5159..0f643760 100644 --- a/config/detekt/detekt.yml +++ b/config/detekt/detekt.yml @@ -6,6 +6,9 @@ complexity: LongParameterList: ignoreDefaultParameters: true TooManyFunctions: + allowedFunctionsPerClass: 60 + allowedFunctionsPerFile: 15 + allowedFunctionsPerInterface: 50 ignoreAnnotatedFunctions: - Preview From a01ba62debccab675b45d1e7374605a7b2dcfbb8 Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Thu, 30 Apr 2026 02:53:10 +0300 Subject: [PATCH 81/99] Fix TooManyFunctions errors --- .../ConversationMessageDataDraftMapper.kt | 2 +- .../ConversationDraftsRepository.kt | 28 +- .../v2/composer/ui/ConversationComposeBar.kt | 289 +------ .../ui/ConversationComposeMessageField.kt | 305 +++++++ .../ui/ConversationSendActionButton.kt | 244 ------ .../ui/ConversationSendActionButtonGesture.kt | 250 ++++++ .../v2/mediapicker/ConversationMediaPicker.kt | 2 +- .../ConversationMediaPickerDelegate.kt | 8 +- .../ConversationMediaPickerOverlay.kt | 2 +- .../ConversationMediaPickerScaffold.kt | 4 +- .../component/ConversationMediaThumbnail.kt | 299 ------- .../ConversationMediaThumbnailBitmapLoader.kt | 306 +++++++ .../ConversationMediaCaptureShutterButton.kt | 280 +++---- .../review/ConversationMediaPickerReview.kt | 2 +- .../ConversationMediaReviewPagerState.kt | 8 +- .../ConversationAttachmentRepository.kt | 4 +- .../ui/message/ConversationMessage.kt | 436 +--------- .../ui/message/ConversationMessageBubble.kt | 448 +++++++++++ .../v2/metadata/ui/ConversationTopAppBar.kt | 64 +- .../RecipientSelectionContactAvatar.kt | 209 +++++ .../RecipientSelectionContactRow.kt | 274 +++++++ .../RecipientSelectionContactsContent.kt | 342 ++++++++ .../RecipientSelectionContent.kt | 744 ------------------ .../RecipientSelectionPrimaryActionButton.kt | 114 +++ .../v2/screen/ConversationViewModel.kt | 4 +- .../ConversationMediaPickerOverlayUiState.kt | 2 +- 26 files changed, 2406 insertions(+), 2264 deletions(-) create mode 100644 src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationComposeMessageField.kt create mode 100644 src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationSendActionButtonGesture.kt create mode 100644 src/com/android/messaging/ui/conversation/v2/mediapicker/component/ConversationMediaThumbnailBitmapLoader.kt create mode 100644 src/com/android/messaging/ui/conversation/v2/messages/ui/message/ConversationMessageBubble.kt create mode 100644 src/com/android/messaging/ui/conversation/v2/recipientpicker/RecipientSelectionContactAvatar.kt create mode 100644 src/com/android/messaging/ui/conversation/v2/recipientpicker/RecipientSelectionContactRow.kt create mode 100644 src/com/android/messaging/ui/conversation/v2/recipientpicker/RecipientSelectionContactsContent.kt create mode 100644 src/com/android/messaging/ui/conversation/v2/recipientpicker/RecipientSelectionPrimaryActionButton.kt diff --git a/src/com/android/messaging/data/conversation/mapper/ConversationMessageDataDraftMapper.kt b/src/com/android/messaging/data/conversation/mapper/ConversationMessageDataDraftMapper.kt index c91f6da0..55b8729d 100644 --- a/src/com/android/messaging/data/conversation/mapper/ConversationMessageDataDraftMapper.kt +++ b/src/com/android/messaging/data/conversation/mapper/ConversationMessageDataDraftMapper.kt @@ -5,8 +5,8 @@ import com.android.messaging.data.conversation.model.draft.ConversationDraftAtta import com.android.messaging.datamodel.data.MessageData import com.android.messaging.datamodel.data.MessagePartData import com.android.messaging.util.LogUtil -import kotlinx.collections.immutable.toImmutableList import javax.inject.Inject +import kotlinx.collections.immutable.toImmutableList internal interface ConversationMessageDataDraftMapper { fun map( diff --git a/src/com/android/messaging/data/conversation/repository/ConversationDraftsRepository.kt b/src/com/android/messaging/data/conversation/repository/ConversationDraftsRepository.kt index fc0e18bb..a8103c99 100644 --- a/src/com/android/messaging/data/conversation/repository/ConversationDraftsRepository.kt +++ b/src/com/android/messaging/data/conversation/repository/ConversationDraftsRepository.kt @@ -214,20 +214,28 @@ internal class ConversationDraftsRepositoryImpl @Inject constructor( conversationId: String, message: MessageData, ): MessageData? { - if (message.selfId != null && message.participantId != null) { + if (hasDraftParticipants(message = message)) { return message } - val selfParticipantId = conversationDraftStore.getSelfParticipantId( - conversationId = conversationId, - ) ?: run { - LogUtil.w( - TAG, - "Conversation $conversationId was deleted before saving draft ${message.messageId}", - ) - return null - } + return conversationDraftStore + .getSelfParticipantId(conversationId) + ?.let { selfParticipantId -> + bindMissingDraftParticipants( + message = message, + selfParticipantId = selfParticipantId, + ) + } + } + private fun hasDraftParticipants(message: MessageData): Boolean { + return message.selfId != null && message.participantId != null + } + + private fun bindMissingDraftParticipants( + message: MessageData, + selfParticipantId: String, + ): MessageData { if (message.selfId == null) { message.bindSelfId(selfParticipantId) } diff --git a/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationComposeBar.kt b/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationComposeBar.kt index 75d8eccd..7fe3a59e 100644 --- a/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationComposeBar.kt +++ b/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationComposeBar.kt @@ -1,6 +1,5 @@ package com.android.messaging.ui.conversation.v2.composer.ui -import androidx.annotation.StringRes import androidx.compose.animation.AnimatedContent import androidx.compose.animation.ContentTransform import androidx.compose.animation.EnterTransition @@ -21,59 +20,25 @@ import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.AddCircleOutline -import androidx.compose.material.icons.rounded.Image -import androidx.compose.material.icons.rounded.Mic -import androidx.compose.material.icons.rounded.Person -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.material3.TextField -import androidx.compose.material3.TextFieldColors -import androidx.compose.material3.TextFieldDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.graphics.Color -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.clearAndSetSemantics import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.semantics.stateDescription -import androidx.compose.ui.semantics.testTag -import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.PopupProperties -import com.android.messaging.R import com.android.messaging.domain.conversation.usecase.draft.model.ConversationDraftSendProtocol -import com.android.messaging.ui.conversation.v2.CONVERSATION_ATTACHMENT_AUDIO_MENU_ITEM_TEST_TAG -import com.android.messaging.ui.conversation.v2.CONVERSATION_ATTACHMENT_BUTTON_TEST_TAG -import com.android.messaging.ui.conversation.v2.CONVERSATION_ATTACHMENT_CONTACT_MENU_ITEM_TEST_TAG -import com.android.messaging.ui.conversation.v2.CONVERSATION_ATTACHMENT_MEDIA_MENU_ITEM_TEST_TAG import com.android.messaging.ui.conversation.v2.CONVERSATION_COMPOSE_BAR_TEST_TAG -import com.android.messaging.ui.conversation.v2.CONVERSATION_MMS_INDICATOR_TEST_TAG import com.android.messaging.ui.conversation.v2.CONVERSATION_SEND_BUTTON_SHAPE_CIRCLE import com.android.messaging.ui.conversation.v2.CONVERSATION_SEND_BUTTON_TEST_TAG -import com.android.messaging.ui.conversation.v2.CONVERSATION_TEXT_FIELD_TEST_TAG import com.android.messaging.ui.conversation.v2.audio.model.ConversationAudioRecordingPhase import com.android.messaging.ui.conversation.v2.audio.model.ConversationAudioRecordingUiState import com.android.messaging.ui.conversation.v2.conversationShape @@ -178,43 +143,7 @@ internal fun ConversationComposeBar( } @Composable -private fun rememberConversationComposeBarPresentation(): ConversationComposeBarPresentation { - val fieldColors = conversationComposeBarTextFieldColors() - - return remember(fieldColors) { - ConversationComposeBarPresentation( - fieldShape = RoundedCornerShape(size = 28.dp), - fieldColors = fieldColors, - ) - } -} - -@Composable -private fun conversationComposeBarTextFieldColors(): TextFieldColors { - return TextFieldDefaults.colors( - focusedContainerColor = MaterialTheme.colorScheme.surfaceContainerHigh, - unfocusedContainerColor = MaterialTheme.colorScheme.surfaceContainerHigh, - disabledContainerColor = MaterialTheme.colorScheme.surfaceContainerHigh, - focusedIndicatorColor = Color.Transparent, - unfocusedIndicatorColor = Color.Transparent, - disabledIndicatorColor = Color.Transparent, - focusedTextColor = MaterialTheme.colorScheme.onSurface, - unfocusedTextColor = MaterialTheme.colorScheme.onSurface, - disabledTextColor = MaterialTheme.colorScheme.onSurface, - focusedPlaceholderColor = MaterialTheme.colorScheme.onSurfaceVariant, - unfocusedPlaceholderColor = MaterialTheme.colorScheme.onSurfaceVariant, - disabledPlaceholderColor = MaterialTheme.colorScheme.onSurfaceVariant, - focusedLeadingIconColor = MaterialTheme.colorScheme.onSurfaceVariant, - unfocusedLeadingIconColor = MaterialTheme.colorScheme.onSurfaceVariant, - disabledLeadingIconColor = MaterialTheme.colorScheme.onSurfaceVariant, - focusedTrailingIconColor = MaterialTheme.colorScheme.onSurfaceVariant, - unfocusedTrailingIconColor = MaterialTheme.colorScheme.onSurfaceVariant, - disabledTrailingIconColor = MaterialTheme.colorScheme.onSurfaceVariant, - ) -} - -@Composable -private fun ConversationComposeInputContent( +internal fun ConversationComposeInputContent( audioRecording: ConversationAudioRecordingUiState, messageText: String, sendProtocol: ConversationDraftSendProtocol, @@ -442,106 +371,6 @@ private fun conversationComposeSendActionMode( } } -@Composable -private fun ConversationComposeMessageField( - modifier: Modifier = Modifier, - value: String, - enabled: Boolean, - sendProtocol: ConversationDraftSendProtocol, - isVisuallyHidden: Boolean, - messageFieldFocusRequester: FocusRequester?, - presentation: ConversationComposeBarPresentation, - isAttachmentActionEnabled: Boolean, - isAudioRecordActionEnabled: Boolean, - onValueChange: (String) -> Unit, - onContactAttachClick: () -> Unit, - onMediaPickerClick: () -> Unit, - onAudioAttachClick: () -> Unit, -) { - val focusRequesterModifier = messageFieldFocusRequester - ?.let(Modifier::focusRequester) - ?: Modifier - - val recordingVisibilityModifier = when { - isVisuallyHidden -> { - Modifier - .alpha(alpha = 0f) - .clearAndSetSemantics {} - } - - else -> Modifier - } - - val mmsText = stringResource(id = R.string.mms_text) - val sendProtocolSemanticsModifier = when (sendProtocol) { - ConversationDraftSendProtocol.MMS -> { - Modifier.semantics { - stateDescription = mmsText - } - } - - ConversationDraftSendProtocol.SMS -> Modifier - } - - TextField( - modifier = modifier - .then(focusRequesterModifier) - .testTag(CONVERSATION_TEXT_FIELD_TEST_TAG) - .heightIn(min = 56.dp) - .then(sendProtocolSemanticsModifier) - .then(recordingVisibilityModifier), - value = value, - onValueChange = onValueChange, - enabled = enabled, - shape = presentation.fieldShape, - colors = presentation.fieldColors, - placeholder = ::ConversationComposePlaceholder, - leadingIcon = { - ConversationComposeAttachmentMenu( - modifier = Modifier.testTag(CONVERSATION_ATTACHMENT_BUTTON_TEST_TAG), - enabled = isAttachmentActionEnabled, - isAudioRecordActionEnabled = isAudioRecordActionEnabled, - onContactAttachClick = onContactAttachClick, - onMediaPickerClick = onMediaPickerClick, - onAudioAttachClick = onAudioAttachClick, - ) - }, - trailingIcon = when (sendProtocol) { - ConversationDraftSendProtocol.MMS -> { - { - MmsIndicator() - } - } - - ConversationDraftSendProtocol.SMS -> null - }, - minLines = 1, - maxLines = 4, - ) -} - -@Composable -private fun MmsIndicator() { - Text( - modifier = Modifier - .padding(end = 12.dp) - .clearAndSetSemantics { - testTag = CONVERSATION_MMS_INDICATOR_TEST_TAG - }, - text = stringResource(id = R.string.mms_text), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.tertiary, - ) -} - -@Composable -private fun ConversationComposePlaceholder() { - Text( - text = stringResource(id = R.string.compose_message_view_hint_text), - style = MaterialTheme.typography.bodyLarge, - ) -} - private fun contentSwapTransition(): ContentTransform { val enterTransition = contentSwapEnterTransition() val exitTransition = contentSwapExitTransition() @@ -575,117 +404,6 @@ private fun contentSwapExitOffset(fullWidth: Int): Int { return -(fullWidth / CONTENT_SWAP_EXIT_SLIDE_OFFSET_DIVISOR) } -@Composable -private fun ConversationComposeAttachmentMenu( - modifier: Modifier = Modifier, - enabled: Boolean, - isAudioRecordActionEnabled: Boolean, - onContactAttachClick: () -> Unit, - onMediaPickerClick: () -> Unit, - onAudioAttachClick: () -> Unit, -) { - val hapticFeedback = LocalHapticFeedback.current - var isExpanded by rememberSaveable { - mutableStateOf(value = false) - } - - fun closeMenuAndRun(action: () -> Unit) { - isExpanded = false - action() - } - - Box( - modifier = modifier, - ) { - IconButton( - onClick = { - hapticFeedback.performHapticFeedback(HapticFeedbackType.ContextClick) - isExpanded = true - }, - enabled = enabled, - ) { - Icon( - imageVector = Icons.Rounded.AddCircleOutline, - contentDescription = stringResource( - id = R.string.attachMediaButtonContentDescription, - ), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - - DropdownMenu( - expanded = isExpanded, - onDismissRequest = { - isExpanded = false - }, - shape = RoundedCornerShape(size = 24.dp), - containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, - tonalElevation = 3.dp, - shadowElevation = 6.dp, - offset = DpOffset( - x = 0.dp, - y = (-8).dp, - ), - properties = PopupProperties( - focusable = false, - ), - ) { - ConversationComposeAttachmentMenuItem( - modifier = Modifier.testTag(CONVERSATION_ATTACHMENT_MEDIA_MENU_ITEM_TEST_TAG), - imageVector = Icons.Rounded.Image, - textResId = R.string.mediapicker_gallery_title, - onClick = { - closeMenuAndRun(action = onMediaPickerClick) - }, - ) - ConversationComposeAttachmentMenuItem( - modifier = Modifier.testTag(CONVERSATION_ATTACHMENT_AUDIO_MENU_ITEM_TEST_TAG), - imageVector = Icons.Rounded.Mic, - textResId = R.string.mediapicker_audio_title, - enabled = isAudioRecordActionEnabled, - onClick = { - closeMenuAndRun(action = onAudioAttachClick) - }, - ) - - ConversationComposeAttachmentMenuItem( - modifier = Modifier.testTag(CONVERSATION_ATTACHMENT_CONTACT_MENU_ITEM_TEST_TAG), - imageVector = Icons.Rounded.Person, - textResId = R.string.mediapicker_contact_title, - onClick = { - closeMenuAndRun(action = onContactAttachClick) - }, - ) - } - } -} - -@Composable -private fun ConversationComposeAttachmentMenuItem( - modifier: Modifier = Modifier, - imageVector: ImageVector, - @StringRes textResId: Int, - enabled: Boolean = true, - onClick: () -> Unit, -) { - DropdownMenuItem( - modifier = modifier, - text = { - Text(text = stringResource(id = textResId)) - }, - leadingIcon = { - Icon( - imageVector = imageVector, - contentDescription = null, - modifier = Modifier.size(size = 24.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - }, - enabled = enabled, - onClick = onClick, - ) -} - @Composable private fun ConversationComposeSendAction( modifier: Modifier = Modifier, @@ -734,11 +452,6 @@ private fun ConversationComposeSendAction( } } -private data class ConversationComposeBarPresentation( - val fieldShape: RoundedCornerShape, - val fieldColors: TextFieldColors, -) - private data class ConversationComposeInputState( val cancelProgress: Float, val lockProgress: Float, diff --git a/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationComposeMessageField.kt b/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationComposeMessageField.kt new file mode 100644 index 00000000..cfac0226 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationComposeMessageField.kt @@ -0,0 +1,305 @@ +package com.android.messaging.ui.conversation.v2.composer.ui + +import androidx.annotation.StringRes +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.AddCircleOutline +import androidx.compose.material.icons.rounded.Image +import androidx.compose.material.icons.rounded.Mic +import androidx.compose.material.icons.rounded.Person +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldColors +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.stateDescription +import androidx.compose.ui.semantics.testTag +import androidx.compose.ui.unit.DpOffset +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.PopupProperties +import com.android.messaging.R +import com.android.messaging.domain.conversation.usecase.draft.model.ConversationDraftSendProtocol +import com.android.messaging.ui.conversation.v2.CONVERSATION_ATTACHMENT_AUDIO_MENU_ITEM_TEST_TAG +import com.android.messaging.ui.conversation.v2.CONVERSATION_ATTACHMENT_BUTTON_TEST_TAG +import com.android.messaging.ui.conversation.v2.CONVERSATION_ATTACHMENT_CONTACT_MENU_ITEM_TEST_TAG +import com.android.messaging.ui.conversation.v2.CONVERSATION_ATTACHMENT_MEDIA_MENU_ITEM_TEST_TAG +import com.android.messaging.ui.conversation.v2.CONVERSATION_MMS_INDICATOR_TEST_TAG +import com.android.messaging.ui.conversation.v2.CONVERSATION_TEXT_FIELD_TEST_TAG + +@Composable +internal fun rememberConversationComposeBarPresentation(): ConversationComposeBarPresentation { + val fieldColors = conversationComposeBarTextFieldColors() + + return remember(fieldColors) { + ConversationComposeBarPresentation( + fieldShape = RoundedCornerShape(size = 28.dp), + fieldColors = fieldColors, + ) + } +} + +@Composable +private fun conversationComposeBarTextFieldColors(): TextFieldColors { + return TextFieldDefaults.colors( + focusedContainerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + unfocusedContainerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + disabledContainerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + disabledIndicatorColor = Color.Transparent, + focusedTextColor = MaterialTheme.colorScheme.onSurface, + unfocusedTextColor = MaterialTheme.colorScheme.onSurface, + disabledTextColor = MaterialTheme.colorScheme.onSurface, + focusedPlaceholderColor = MaterialTheme.colorScheme.onSurfaceVariant, + unfocusedPlaceholderColor = MaterialTheme.colorScheme.onSurfaceVariant, + disabledPlaceholderColor = MaterialTheme.colorScheme.onSurfaceVariant, + focusedLeadingIconColor = MaterialTheme.colorScheme.onSurfaceVariant, + unfocusedLeadingIconColor = MaterialTheme.colorScheme.onSurfaceVariant, + disabledLeadingIconColor = MaterialTheme.colorScheme.onSurfaceVariant, + focusedTrailingIconColor = MaterialTheme.colorScheme.onSurfaceVariant, + unfocusedTrailingIconColor = MaterialTheme.colorScheme.onSurfaceVariant, + disabledTrailingIconColor = MaterialTheme.colorScheme.onSurfaceVariant, + ) +} + +@Composable +internal fun ConversationComposeMessageField( + modifier: Modifier = Modifier, + value: String, + enabled: Boolean, + sendProtocol: ConversationDraftSendProtocol, + isVisuallyHidden: Boolean, + messageFieldFocusRequester: FocusRequester?, + presentation: ConversationComposeBarPresentation, + isAttachmentActionEnabled: Boolean, + isAudioRecordActionEnabled: Boolean, + onValueChange: (String) -> Unit, + onContactAttachClick: () -> Unit, + onMediaPickerClick: () -> Unit, + onAudioAttachClick: () -> Unit, +) { + val focusRequesterModifier = messageFieldFocusRequester + ?.let(Modifier::focusRequester) + ?: Modifier + + val recordingVisibilityModifier = when { + isVisuallyHidden -> { + Modifier + .alpha(alpha = 0f) + .clearAndSetSemantics {} + } + + else -> Modifier + } + + val mmsText = stringResource(id = R.string.mms_text) + val sendProtocolSemanticsModifier = when (sendProtocol) { + ConversationDraftSendProtocol.MMS -> { + Modifier.semantics { + stateDescription = mmsText + } + } + + ConversationDraftSendProtocol.SMS -> Modifier + } + + TextField( + modifier = modifier + .then(focusRequesterModifier) + .testTag(CONVERSATION_TEXT_FIELD_TEST_TAG) + .heightIn(min = 56.dp) + .then(sendProtocolSemanticsModifier) + .then(recordingVisibilityModifier), + value = value, + onValueChange = onValueChange, + enabled = enabled, + shape = presentation.fieldShape, + colors = presentation.fieldColors, + placeholder = ::ConversationComposePlaceholder, + leadingIcon = { + ConversationComposeAttachmentMenu( + modifier = Modifier.testTag(CONVERSATION_ATTACHMENT_BUTTON_TEST_TAG), + enabled = isAttachmentActionEnabled, + isAudioRecordActionEnabled = isAudioRecordActionEnabled, + onContactAttachClick = onContactAttachClick, + onMediaPickerClick = onMediaPickerClick, + onAudioAttachClick = onAudioAttachClick, + ) + }, + trailingIcon = when (sendProtocol) { + ConversationDraftSendProtocol.MMS -> { + { + MmsIndicator() + } + } + + ConversationDraftSendProtocol.SMS -> null + }, + minLines = 1, + maxLines = 4, + ) +} + +@Composable +private fun MmsIndicator() { + Text( + modifier = Modifier + .padding(end = 12.dp) + .clearAndSetSemantics { + testTag = CONVERSATION_MMS_INDICATOR_TEST_TAG + }, + text = stringResource(id = R.string.mms_text), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.tertiary, + ) +} + +@Composable +private fun ConversationComposePlaceholder() { + Text( + text = stringResource(id = R.string.compose_message_view_hint_text), + style = MaterialTheme.typography.bodyLarge, + ) +} + +@Composable +private fun ConversationComposeAttachmentMenu( + modifier: Modifier = Modifier, + enabled: Boolean, + isAudioRecordActionEnabled: Boolean, + onContactAttachClick: () -> Unit, + onMediaPickerClick: () -> Unit, + onAudioAttachClick: () -> Unit, +) { + val hapticFeedback = LocalHapticFeedback.current + var isExpanded by rememberSaveable { + mutableStateOf(value = false) + } + + fun closeMenuAndRun(action: () -> Unit) { + isExpanded = false + action() + } + + Box( + modifier = modifier, + ) { + IconButton( + onClick = { + hapticFeedback.performHapticFeedback(HapticFeedbackType.ContextClick) + isExpanded = true + }, + enabled = enabled, + ) { + Icon( + imageVector = Icons.Rounded.AddCircleOutline, + contentDescription = stringResource( + id = R.string.attachMediaButtonContentDescription, + ), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + DropdownMenu( + expanded = isExpanded, + onDismissRequest = { + isExpanded = false + }, + shape = RoundedCornerShape(size = 24.dp), + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + tonalElevation = 3.dp, + shadowElevation = 6.dp, + offset = DpOffset( + x = 0.dp, + y = (-8).dp, + ), + properties = PopupProperties( + focusable = false, + ), + ) { + ConversationComposeAttachmentMenuItem( + modifier = Modifier.testTag(CONVERSATION_ATTACHMENT_MEDIA_MENU_ITEM_TEST_TAG), + imageVector = Icons.Rounded.Image, + textResId = R.string.mediapicker_gallery_title, + onClick = { + closeMenuAndRun(action = onMediaPickerClick) + }, + ) + ConversationComposeAttachmentMenuItem( + modifier = Modifier.testTag(CONVERSATION_ATTACHMENT_AUDIO_MENU_ITEM_TEST_TAG), + imageVector = Icons.Rounded.Mic, + textResId = R.string.mediapicker_audio_title, + enabled = isAudioRecordActionEnabled, + onClick = { + closeMenuAndRun(action = onAudioAttachClick) + }, + ) + + ConversationComposeAttachmentMenuItem( + modifier = Modifier.testTag(CONVERSATION_ATTACHMENT_CONTACT_MENU_ITEM_TEST_TAG), + imageVector = Icons.Rounded.Person, + textResId = R.string.mediapicker_contact_title, + onClick = { + closeMenuAndRun(action = onContactAttachClick) + }, + ) + } + } +} + +@Composable +private fun ConversationComposeAttachmentMenuItem( + modifier: Modifier = Modifier, + imageVector: ImageVector, + @StringRes textResId: Int, + enabled: Boolean = true, + onClick: () -> Unit, +) { + DropdownMenuItem( + modifier = modifier, + text = { + Text(text = stringResource(id = textResId)) + }, + leadingIcon = { + Icon( + imageVector = imageVector, + contentDescription = null, + modifier = Modifier.size(size = 24.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + enabled = enabled, + onClick = onClick, + ) +} + +internal data class ConversationComposeBarPresentation( + val fieldShape: RoundedCornerShape, + val fieldColors: TextFieldColors, +) diff --git a/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationSendActionButton.kt b/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationSendActionButton.kt index 1e7d342b..ac611a68 100644 --- a/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationSendActionButton.kt +++ b/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationSendActionButton.kt @@ -18,9 +18,6 @@ import androidx.compose.animation.scaleIn import androidx.compose.animation.scaleOut import androidx.compose.animation.togetherWith import androidx.compose.foundation.background -import androidx.compose.foundation.gestures.awaitEachGesture -import androidx.compose.foundation.gestures.awaitFirstDown -import androidx.compose.foundation.gestures.awaitLongPressOrCancellation import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.size @@ -37,16 +34,11 @@ import androidx.compose.runtime.Immutable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.scale import androidx.compose.ui.graphics.Color -import androidx.compose.ui.input.pointer.AwaitPointerEventScope -import androidx.compose.ui.input.pointer.PointerId -import androidx.compose.ui.input.pointer.PointerInputChange -import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource @@ -62,12 +54,6 @@ internal enum class ConversationSendActionButtonMode { Stop, } -@Immutable -internal data class ConversationSendActionButtonGestureState( - val cancelDragDistancePx: Float = 0f, - val lockDragDistancePx: Float = 0f, -) - @Immutable private data class ConversationSendActionButtonVisualState( val buttonScale: Float, @@ -223,236 +209,6 @@ private fun animateConversationSendActionButtonVisualState( ) } -@Composable -private fun Modifier.conversationSendActionButtonGesture( - mode: ConversationSendActionButtonMode, - enabled: Boolean, - cancelThresholdPx: Float, - lockThresholdPx: Float, - isRecordingActive: Boolean, - isRecordingLocked: Boolean, - onGestureActiveChange: (Boolean) -> Unit, - onRecordGestureStart: () -> Unit, - onRecordGestureMove: (ConversationSendActionButtonGestureState) -> Unit, - onRecordGestureLock: () -> Boolean, - onRecordGestureFinish: (Boolean) -> Unit, - onLockedStopClick: () -> Unit, -): Modifier { - val currentIsRecordingActive by rememberUpdatedState(newValue = isRecordingActive) - val currentIsRecordingLocked by rememberUpdatedState(newValue = isRecordingLocked) - val currentOnGestureActiveChange by rememberUpdatedState(newValue = onGestureActiveChange) - val currentOnRecordGestureStart by rememberUpdatedState(newValue = onRecordGestureStart) - val currentOnRecordGestureMove by rememberUpdatedState(newValue = onRecordGestureMove) - val currentOnRecordGestureLock by rememberUpdatedState(newValue = onRecordGestureLock) - val currentOnRecordGestureFinish by rememberUpdatedState(newValue = onRecordGestureFinish) - val currentOnLockedStopClick by rememberUpdatedState(newValue = onLockedStopClick) - - return when { - mode != ConversationSendActionButtonMode.Send && enabled -> { - pointerInput( - mode, - enabled, - cancelThresholdPx, - lockThresholdPx, - ) { - awaitEachGesture { - when { - currentIsRecordingActive && currentIsRecordingLocked -> { - handleLockedRecordGesture( - cancelThresholdPx = cancelThresholdPx, - onGestureActiveChange = currentOnGestureActiveChange, - onRecordGestureMove = currentOnRecordGestureMove, - onRecordGestureFinish = currentOnRecordGestureFinish, - onLockedStopClick = currentOnLockedStopClick, - ) - } - - else -> { - handleRecordGesture( - cancelThresholdPx = cancelThresholdPx, - lockThresholdPx = lockThresholdPx, - onGestureActiveChange = currentOnGestureActiveChange, - onRecordGestureStart = currentOnRecordGestureStart, - onRecordGestureMove = currentOnRecordGestureMove, - onRecordGestureLock = currentOnRecordGestureLock, - onRecordGestureFinish = currentOnRecordGestureFinish, - ) - } - } - } - } - } - - else -> this - } -} - -private suspend fun AwaitPointerEventScope.handleRecordGesture( - cancelThresholdPx: Float, - lockThresholdPx: Float, - onGestureActiveChange: (Boolean) -> Unit, - onRecordGestureStart: () -> Unit, - onRecordGestureMove: (ConversationSendActionButtonGestureState) -> Unit, - onRecordGestureLock: () -> Boolean, - onRecordGestureFinish: (Boolean) -> Unit, -) { - val initialDown = awaitFirstDown(requireUnconsumed = false) - - val longPressChange = awaitLongPressOrCancellation(pointerId = initialDown.id) - ?: return - - onGestureActiveChange(true) - onRecordGestureStart() - - trackRecordGestureDrag( - initialDown = initialDown, - longPressChange = longPressChange, - cancelThresholdPx = cancelThresholdPx, - lockThresholdPx = lockThresholdPx, - onGestureActiveChange = onGestureActiveChange, - onRecordGestureMove = onRecordGestureMove, - onRecordGestureLock = onRecordGestureLock, - onRecordGestureFinish = onRecordGestureFinish, - ) -} - -private suspend fun AwaitPointerEventScope.trackRecordGestureDrag( - initialDown: PointerInputChange, - longPressChange: PointerInputChange, - cancelThresholdPx: Float, - lockThresholdPx: Float, - onGestureActiveChange: (Boolean) -> Unit, - onRecordGestureMove: (ConversationSendActionButtonGestureState) -> Unit, - onRecordGestureLock: () -> Boolean, - onRecordGestureFinish: (Boolean) -> Unit, -) { - var isRecordingLocked = false - - longPressChange.consume() - - while (true) { - val pointerChange = awaitRecordGestureChange(pointerId = initialDown.id) ?: break - val gestureState = calculateRecordGestureState( - initialDown = initialDown, - pointerChange = pointerChange, - ) - - if (!isRecordingLocked) { - onRecordGestureMove(gestureState) - - if (gestureState.lockDragDistancePx >= lockThresholdPx) { - isRecordingLocked = onRecordGestureLock() - - if (isRecordingLocked) { - onRecordGestureMove(ConversationSendActionButtonGestureState()) - } - } - } - - pointerChange.consume() - - if (pointerChange.pressed) { - continue - } - - resetRecordGestureDragUi( - onGestureActiveChange = onGestureActiveChange, - onRecordGestureMove = onRecordGestureMove, - ) - - if (!isRecordingLocked) { - onRecordGestureFinish(gestureState.cancelDragDistancePx >= cancelThresholdPx) - } - - return - } - - resetRecordGestureDragUi( - onGestureActiveChange = onGestureActiveChange, - onRecordGestureMove = onRecordGestureMove, - ) -} - -private suspend fun AwaitPointerEventScope.handleLockedRecordGesture( - cancelThresholdPx: Float, - onGestureActiveChange: (Boolean) -> Unit, - onRecordGestureMove: (ConversationSendActionButtonGestureState) -> Unit, - onRecordGestureFinish: (Boolean) -> Unit, - onLockedStopClick: () -> Unit, -) { - val initialDown = awaitFirstDown(requireUnconsumed = false) - - onGestureActiveChange(true) - initialDown.consume() - - while (true) { - val pointerChange = awaitRecordGestureChange(pointerId = initialDown.id) ?: break - val gestureState = calculateRecordGestureState( - initialDown = initialDown, - pointerChange = pointerChange, - ) - - onRecordGestureMove( - ConversationSendActionButtonGestureState( - cancelDragDistancePx = gestureState.cancelDragDistancePx, - ), - ) - pointerChange.consume() - - if (!pointerChange.pressed) { - resetRecordGestureDragUi( - onGestureActiveChange = onGestureActiveChange, - onRecordGestureMove = onRecordGestureMove, - ) - when { - gestureState.cancelDragDistancePx >= cancelThresholdPx -> { - onRecordGestureFinish(true) - } - - else -> { - onLockedStopClick() - } - } - return - } - } - - resetRecordGestureDragUi( - onGestureActiveChange = onGestureActiveChange, - onRecordGestureMove = onRecordGestureMove, - ) -} - -private fun resetRecordGestureDragUi( - onGestureActiveChange: (Boolean) -> Unit, - onRecordGestureMove: (ConversationSendActionButtonGestureState) -> Unit, -) { - onGestureActiveChange(false) - onRecordGestureMove(ConversationSendActionButtonGestureState()) -} - -private suspend fun AwaitPointerEventScope.awaitRecordGestureChange( - pointerId: PointerId, -): PointerInputChange? { - return awaitPointerEvent() - .changes - .firstOrNull { change -> - change.id == pointerId - } -} - -private fun calculateRecordGestureState( - initialDown: PointerInputChange, - pointerChange: PointerInputChange, -): ConversationSendActionButtonGestureState { - return ConversationSendActionButtonGestureState( - cancelDragDistancePx = (initialDown.position.x - pointerChange.position.x) - .coerceAtLeast(minimumValue = 0f), - lockDragDistancePx = (initialDown.position.y - pointerChange.position.y) - .coerceAtLeast(minimumValue = 0f), - ) -} - @Composable private fun ConversationSendActionButtonLayout( modifier: Modifier, diff --git a/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationSendActionButtonGesture.kt b/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationSendActionButtonGesture.kt new file mode 100644 index 00000000..5c56da38 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationSendActionButtonGesture.kt @@ -0,0 +1,250 @@ +package com.android.messaging.ui.conversation.v2.composer.ui + +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.gestures.awaitLongPressOrCancellation +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.pointer.AwaitPointerEventScope +import androidx.compose.ui.input.pointer.PointerId +import androidx.compose.ui.input.pointer.PointerInputChange +import androidx.compose.ui.input.pointer.pointerInput + +@Immutable +internal data class ConversationSendActionButtonGestureState( + val cancelDragDistancePx: Float = 0f, + val lockDragDistancePx: Float = 0f, +) + +@Composable +internal fun Modifier.conversationSendActionButtonGesture( + mode: ConversationSendActionButtonMode, + enabled: Boolean, + cancelThresholdPx: Float, + lockThresholdPx: Float, + isRecordingActive: Boolean, + isRecordingLocked: Boolean, + onGestureActiveChange: (Boolean) -> Unit, + onRecordGestureStart: () -> Unit, + onRecordGestureMove: (ConversationSendActionButtonGestureState) -> Unit, + onRecordGestureLock: () -> Boolean, + onRecordGestureFinish: (Boolean) -> Unit, + onLockedStopClick: () -> Unit, +): Modifier { + val currentIsRecordingActive by rememberUpdatedState(newValue = isRecordingActive) + val currentIsRecordingLocked by rememberUpdatedState(newValue = isRecordingLocked) + val currentOnGestureActiveChange by rememberUpdatedState(newValue = onGestureActiveChange) + val currentOnRecordGestureStart by rememberUpdatedState(newValue = onRecordGestureStart) + val currentOnRecordGestureMove by rememberUpdatedState(newValue = onRecordGestureMove) + val currentOnRecordGestureLock by rememberUpdatedState(newValue = onRecordGestureLock) + val currentOnRecordGestureFinish by rememberUpdatedState(newValue = onRecordGestureFinish) + val currentOnLockedStopClick by rememberUpdatedState(newValue = onLockedStopClick) + + return when { + mode != ConversationSendActionButtonMode.Send && enabled -> { + pointerInput( + mode, + enabled, + cancelThresholdPx, + lockThresholdPx, + ) { + awaitEachGesture { + when { + currentIsRecordingActive && currentIsRecordingLocked -> { + handleLockedRecordGesture( + cancelThresholdPx = cancelThresholdPx, + onGestureActiveChange = currentOnGestureActiveChange, + onRecordGestureMove = currentOnRecordGestureMove, + onRecordGestureFinish = currentOnRecordGestureFinish, + onLockedStopClick = currentOnLockedStopClick, + ) + } + + else -> { + handleRecordGesture( + cancelThresholdPx = cancelThresholdPx, + lockThresholdPx = lockThresholdPx, + onGestureActiveChange = currentOnGestureActiveChange, + onRecordGestureStart = currentOnRecordGestureStart, + onRecordGestureMove = currentOnRecordGestureMove, + onRecordGestureLock = currentOnRecordGestureLock, + onRecordGestureFinish = currentOnRecordGestureFinish, + ) + } + } + } + } + } + + else -> this + } +} + +private suspend fun AwaitPointerEventScope.handleRecordGesture( + cancelThresholdPx: Float, + lockThresholdPx: Float, + onGestureActiveChange: (Boolean) -> Unit, + onRecordGestureStart: () -> Unit, + onRecordGestureMove: (ConversationSendActionButtonGestureState) -> Unit, + onRecordGestureLock: () -> Boolean, + onRecordGestureFinish: (Boolean) -> Unit, +) { + val initialDown = awaitFirstDown(requireUnconsumed = false) + + val longPressChange = awaitLongPressOrCancellation(pointerId = initialDown.id) + ?: return + + onGestureActiveChange(true) + onRecordGestureStart() + + trackRecordGestureDrag( + initialDown = initialDown, + longPressChange = longPressChange, + cancelThresholdPx = cancelThresholdPx, + lockThresholdPx = lockThresholdPx, + onGestureActiveChange = onGestureActiveChange, + onRecordGestureMove = onRecordGestureMove, + onRecordGestureLock = onRecordGestureLock, + onRecordGestureFinish = onRecordGestureFinish, + ) +} + +private suspend fun AwaitPointerEventScope.trackRecordGestureDrag( + initialDown: PointerInputChange, + longPressChange: PointerInputChange, + cancelThresholdPx: Float, + lockThresholdPx: Float, + onGestureActiveChange: (Boolean) -> Unit, + onRecordGestureMove: (ConversationSendActionButtonGestureState) -> Unit, + onRecordGestureLock: () -> Boolean, + onRecordGestureFinish: (Boolean) -> Unit, +) { + var isRecordingLocked = false + + longPressChange.consume() + + while (true) { + val pointerChange = awaitRecordGestureChange(pointerId = initialDown.id) ?: break + val gestureState = calculateRecordGestureState( + initialDown = initialDown, + pointerChange = pointerChange, + ) + + if (!isRecordingLocked) { + onRecordGestureMove(gestureState) + + if (gestureState.lockDragDistancePx >= lockThresholdPx) { + isRecordingLocked = onRecordGestureLock() + + if (isRecordingLocked) { + onRecordGestureMove(ConversationSendActionButtonGestureState()) + } + } + } + + pointerChange.consume() + + if (pointerChange.pressed) { + continue + } + + resetRecordGestureDragUi( + onGestureActiveChange = onGestureActiveChange, + onRecordGestureMove = onRecordGestureMove, + ) + + if (!isRecordingLocked) { + onRecordGestureFinish(gestureState.cancelDragDistancePx >= cancelThresholdPx) + } + + return + } + + resetRecordGestureDragUi( + onGestureActiveChange = onGestureActiveChange, + onRecordGestureMove = onRecordGestureMove, + ) +} + +private suspend fun AwaitPointerEventScope.handleLockedRecordGesture( + cancelThresholdPx: Float, + onGestureActiveChange: (Boolean) -> Unit, + onRecordGestureMove: (ConversationSendActionButtonGestureState) -> Unit, + onRecordGestureFinish: (Boolean) -> Unit, + onLockedStopClick: () -> Unit, +) { + val initialDown = awaitFirstDown(requireUnconsumed = false) + + onGestureActiveChange(true) + initialDown.consume() + + while (true) { + val pointerChange = awaitRecordGestureChange(pointerId = initialDown.id) ?: break + val gestureState = calculateRecordGestureState( + initialDown = initialDown, + pointerChange = pointerChange, + ) + + onRecordGestureMove( + ConversationSendActionButtonGestureState( + cancelDragDistancePx = gestureState.cancelDragDistancePx, + ), + ) + pointerChange.consume() + + if (!pointerChange.pressed) { + resetRecordGestureDragUi( + onGestureActiveChange = onGestureActiveChange, + onRecordGestureMove = onRecordGestureMove, + ) + when { + gestureState.cancelDragDistancePx >= cancelThresholdPx -> { + onRecordGestureFinish(true) + } + + else -> { + onLockedStopClick() + } + } + return + } + } + + resetRecordGestureDragUi( + onGestureActiveChange = onGestureActiveChange, + onRecordGestureMove = onRecordGestureMove, + ) +} + +private fun resetRecordGestureDragUi( + onGestureActiveChange: (Boolean) -> Unit, + onRecordGestureMove: (ConversationSendActionButtonGestureState) -> Unit, +) { + onGestureActiveChange(false) + onRecordGestureMove(ConversationSendActionButtonGestureState()) +} + +private suspend fun AwaitPointerEventScope.awaitRecordGestureChange( + pointerId: PointerId, +): PointerInputChange? { + return awaitPointerEvent() + .changes + .firstOrNull { change -> + change.id == pointerId + } +} + +private fun calculateRecordGestureState( + initialDown: PointerInputChange, + pointerChange: PointerInputChange, +): ConversationSendActionButtonGestureState { + return ConversationSendActionButtonGestureState( + cancelDragDistancePx = (initialDown.position.x - pointerChange.position.x) + .coerceAtLeast(minimumValue = 0f), + lockDragDistancePx = (initialDown.position.y - pointerChange.position.y) + .coerceAtLeast(minimumValue = 0f), + ) +} diff --git a/src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationMediaPicker.kt b/src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationMediaPicker.kt index 6bafb197..2097245f 100644 --- a/src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationMediaPicker.kt +++ b/src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationMediaPicker.kt @@ -173,7 +173,7 @@ internal fun ConversationMediaPicker( onAttachmentCaptionChange = onAttachmentCaptionChange, onAttachmentRemove = onPickerBackedAttachmentRemove, photoPickerSourceContentUriByAttachmentContentUri = - photoPickerSourceContentUriByAttachmentContentUri, + photoPickerSourceContentUriByAttachmentContentUri, onRequestAudioPermission = onRequestAudioPermission, onRequestCameraPermission = onRequestCameraPermission, onCapturedMediaReady = onCapturedMediaReady, diff --git a/src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationMediaPickerDelegate.kt b/src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationMediaPickerDelegate.kt index 4913406c..19e92400 100644 --- a/src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationMediaPickerDelegate.kt +++ b/src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationMediaPickerDelegate.kt @@ -10,6 +10,10 @@ import com.android.messaging.ui.conversation.v2.mediapicker.model.PhotoPickerDra import com.android.messaging.ui.conversation.v2.mediapicker.repository.ConversationAttachmentRepository import com.android.messaging.ui.conversation.v2.screen.model.ConversationScreenEffect import com.android.messaging.util.LogUtil +import javax.inject.Inject +import kotlinx.collections.immutable.ImmutableMap +import kotlinx.collections.immutable.persistentMapOf +import kotlinx.collections.immutable.toPersistentMap import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope @@ -26,10 +30,6 @@ import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch -import kotlinx.collections.immutable.ImmutableMap -import kotlinx.collections.immutable.persistentMapOf -import kotlinx.collections.immutable.toPersistentMap -import javax.inject.Inject internal interface ConversationMediaPickerDelegate { val effects: Flow diff --git a/src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationMediaPickerOverlay.kt b/src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationMediaPickerOverlay.kt index 04fae0ba..4420693b 100644 --- a/src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationMediaPickerOverlay.kt +++ b/src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationMediaPickerOverlay.kt @@ -94,7 +94,7 @@ internal fun ConversationMediaPickerOverlay( onAttachmentCaptionChange = onAttachmentCaptionChange, onAttachmentRemove = onAttachmentRemove, photoPickerSourceContentUriByAttachmentContentUri = - photoPickerSourceContentUriByAttachmentContentUri, + photoPickerSourceContentUriByAttachmentContentUri, onPhotoPickerMediaSelected = onPhotoPickerMediaSelected, onPhotoPickerMediaDeselected = onPhotoPickerMediaDeselected, onRequestAudioPermission = { diff --git a/src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationMediaPickerScaffold.kt b/src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationMediaPickerScaffold.kt index 8b276f29..b494758a 100644 --- a/src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationMediaPickerScaffold.kt +++ b/src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationMediaPickerScaffold.kt @@ -83,7 +83,7 @@ internal fun ConversationMediaPickerScaffold( onAttachmentCaptionChange = onAttachmentCaptionChange, onAttachmentRemove = onAttachmentRemove, photoPickerSourceContentUriByAttachmentContentUri = - photoPickerSourceContentUriByAttachmentContentUri, + photoPickerSourceContentUriByAttachmentContentUri, onRequestAudioPermission = onRequestAudioPermission, onRequestCameraPermission = onRequestCameraPermission, onCapturedMediaReady = onCapturedMediaReady, @@ -159,7 +159,7 @@ private fun ConversationMediaPickerOverlayHost( reviewRequestSequence = reviewRequestSequence, isSendActionEnabled = isSendActionEnabled, photoPickerSourceContentUriByAttachmentContentUri = - photoPickerSourceContentUriByAttachmentContentUri, + photoPickerSourceContentUriByAttachmentContentUri, onAttachmentPreviewClick = onAttachmentPreviewClick, onCaptionChange = onAttachmentCaptionChange, onAttachmentRemove = onAttachmentRemove, diff --git a/src/com/android/messaging/ui/conversation/v2/mediapicker/component/ConversationMediaThumbnail.kt b/src/com/android/messaging/ui/conversation/v2/mediapicker/component/ConversationMediaThumbnail.kt index f65ba4ab..14558cf2 100644 --- a/src/com/android/messaging/ui/conversation/v2/mediapicker/component/ConversationMediaThumbnail.kt +++ b/src/com/android/messaging/ui/conversation/v2/mediapicker/component/ConversationMediaThumbnail.kt @@ -1,12 +1,8 @@ package com.android.messaging.ui.conversation.v2.mediapicker.component -import android.content.ContentResolver import android.content.Context import android.graphics.Bitmap -import android.graphics.BitmapFactory -import android.graphics.BitmapFactory.Options import android.net.Uri -import android.util.Size import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween import androidx.compose.foundation.Image @@ -28,21 +24,11 @@ import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.IntSize -import androidx.core.graphics.scale import androidx.core.net.toUri import coil3.compose.AsyncImage import coil3.request.ImageRequest import com.android.messaging.util.ContentType -import com.android.messaging.util.MediaMetadataRetrieverWrapper -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -private const val FIRST_PASS_DIVISOR = 12 -private const val FIRST_PASS_MAXIMUM_SIZE = 24 -private const val FIRST_PASS_MINIMUM_SIZE = 12 -private const val SECOND_PASS_DIVISOR = 3 -private const val SECOND_PASS_MAXIMUM_SIZE = 48 -private const val SECOND_PASS_MINIMUM_SIZE = 24 private const val THUMBNAIL_FADE_IN_DURATION_MILLIS = 90 @Composable @@ -235,105 +221,6 @@ private fun ThumbnailPlaceholder( } } -internal suspend fun loadConversationMediaThumbnailBitmap( - contentResolver: ContentResolver, - contentUri: Uri, - contentType: String, - size: IntSize, - softenBitmap: Boolean, -): Bitmap? { - return withContext(context = Dispatchers.IO) { - val rawBitmap = loadPlatformThumbnail( - contentResolver = contentResolver, - contentUri = contentUri, - size = size, - ) ?: loadFallbackBitmap( - contentResolver = contentResolver, - contentUri = contentUri, - contentType = contentType, - size = size, - ) - - maybeSoftenBitmap( - bitmap = rawBitmap, - outputSize = size, - softenBitmap = softenBitmap, - ) - } -} - -private fun createSoftenedBitmap( - sourceBitmap: Bitmap, - outputSize: IntSize, -): Bitmap { - val sanitizedOutputSize = outputSize.sanitized() - val targetWidth = sanitizedOutputSize.width - val targetHeight = sanitizedOutputSize.height - val centerCroppedBitmap = createCenterCroppedBitmap( - sourceBitmap = sourceBitmap, - targetSize = sanitizedOutputSize, - ) - - // Multi-pass downscaling keeps softened placeholders smooth without introducing blur kernels - val firstPassWidth = (targetWidth / FIRST_PASS_DIVISOR).coerceIn( - minimumValue = FIRST_PASS_MINIMUM_SIZE, - maximumValue = FIRST_PASS_MAXIMUM_SIZE, - ) - val firstPassHeight = (targetHeight / FIRST_PASS_DIVISOR).coerceIn( - minimumValue = FIRST_PASS_MINIMUM_SIZE, - maximumValue = FIRST_PASS_MAXIMUM_SIZE, - ) - val secondPassWidth = (targetWidth / SECOND_PASS_DIVISOR).coerceIn( - minimumValue = SECOND_PASS_MINIMUM_SIZE, - maximumValue = SECOND_PASS_MAXIMUM_SIZE, - ) - val secondPassHeight = (targetHeight / SECOND_PASS_DIVISOR).coerceIn( - minimumValue = SECOND_PASS_MINIMUM_SIZE, - maximumValue = SECOND_PASS_MAXIMUM_SIZE, - ) - - val firstPassBitmap = centerCroppedBitmap.scale(firstPassWidth, firstPassHeight) - val secondPassBitmap = firstPassBitmap.scale(secondPassWidth, secondPassHeight) - - return secondPassBitmap.scale(targetWidth, targetHeight) -} - -private fun createCenterCroppedBitmap( - sourceBitmap: Bitmap, - targetSize: IntSize, -): Bitmap { - val sanitizedTargetSize = targetSize.sanitized() - val targetAspectRatio = - sanitizedTargetSize.width.toFloat() / sanitizedTargetSize.height.toFloat() - val sourceAspectRatio = sourceBitmap.width.toFloat() / sourceBitmap.height.toFloat() - - val cropWidth: Int - val cropHeight: Int - - when { - sourceAspectRatio > targetAspectRatio -> { - cropHeight = sourceBitmap.height - cropWidth = (cropHeight * targetAspectRatio).toInt() - } - - else -> { - cropWidth = sourceBitmap.width - cropHeight = (cropWidth / targetAspectRatio).toInt() - } - } - - val left = (sourceBitmap.width - cropWidth) / 2 - val top = (sourceBitmap.height - cropHeight) / 2 - - return Bitmap.createBitmap( - sourceBitmap, - left, - top, - cropWidth.coerceAtLeast(minimumValue = 1), - cropHeight.coerceAtLeast(minimumValue = 1), - ) -} - @Composable private fun rememberContentUri( contentUri: String, @@ -386,189 +273,3 @@ private fun resolveBitmapFilterQuality(useBitmapLoader: Boolean): FilterQuality else -> FilterQuality.Low } } - -private fun loadPlatformThumbnail( - contentResolver: ContentResolver, - contentUri: Uri, - size: IntSize, -): Bitmap? { - return runCatching { - contentResolver.loadThumbnail( - contentUri, - Size(size.width, size.height), - null, - ) - }.getOrNull() -} - -private fun loadFallbackBitmap( - contentResolver: ContentResolver, - contentUri: Uri, - contentType: String, - size: IntSize, -): Bitmap? { - return when { - ContentType.isImageType(contentType) -> { - loadImageBitmapFallback( - contentResolver = contentResolver, - contentUri = contentUri, - size = size, - ) - } - - ContentType.isVideoType(contentType) -> { - loadVideoFrameFallback( - contentUri = contentUri, - size = size, - ) - } - - else -> null - } -} - -private fun loadImageBitmapFallback( - contentResolver: ContentResolver, - contentUri: Uri, - size: IntSize, -): Bitmap? { - return runCatching { - val decodeBoundsOptions = Options().apply { - inJustDecodeBounds = true - } - - contentResolver - .openInputStream(contentUri) - ?.use { inputStream -> - BitmapFactory.decodeStream(inputStream, null, decodeBoundsOptions) - } - - val decodeBitmapOptions = Options().apply { - inSampleSize = calculateBitmapSampleSize( - sourceWidth = decodeBoundsOptions.outWidth, - sourceHeight = decodeBoundsOptions.outHeight, - targetSize = size, - ) - } - - contentResolver - .openInputStream(contentUri) - ?.use { inputStream -> - BitmapFactory.decodeStream(inputStream, null, decodeBitmapOptions) - } - }.getOrNull() -} - -private fun loadVideoFrameFallback( - contentUri: Uri, - size: IntSize, -): Bitmap? { - val retriever = MediaMetadataRetrieverWrapper() - - return try { - runCatching { - retriever.setDataSource(contentUri) - retriever.frameAtTime?.let { bitmap -> - scaleBitmapDownIfNeeded( - bitmap = bitmap, - targetSize = size, - ) - } - }.getOrNull() - } finally { - retriever.release() - } -} - -private fun maybeSoftenBitmap( - bitmap: Bitmap?, - outputSize: IntSize, - softenBitmap: Boolean, -): Bitmap? { - return when { - bitmap == null -> null - !softenBitmap -> bitmap - - else -> { - createSoftenedBitmap( - sourceBitmap = bitmap, - outputSize = outputSize, - ) - } - } -} - -private fun calculateBitmapSampleSize( - sourceWidth: Int, - sourceHeight: Int, - targetSize: IntSize, -): Int { - if (sourceWidth <= 0 || sourceHeight <= 0) { - return 1 - } - - var sampleSize = 1 - val targetWidth = targetSize.width.coerceAtLeast(minimumValue = 1) - val targetHeight = targetSize.height.coerceAtLeast(minimumValue = 1) - - while ( - canDoubleBitmapSampleSize( - sourceWidth = sourceWidth, - sourceHeight = sourceHeight, - sampleSize = sampleSize, - targetWidth = targetWidth, - targetHeight = targetHeight, - ) - ) { - sampleSize *= 2 - } - - return sampleSize -} - -private fun canDoubleBitmapSampleSize( - sourceWidth: Int, - sourceHeight: Int, - sampleSize: Int, - targetWidth: Int, - targetHeight: Int, -): Boolean { - val doubledSampleSize = sampleSize * 2 - val doubledDecodedWidth = sourceWidth / doubledSampleSize - val doubledDecodedHeight = sourceHeight / doubledSampleSize - - return doubledDecodedWidth >= targetWidth && - doubledDecodedHeight >= targetHeight -} - -private fun scaleBitmapDownIfNeeded( - bitmap: Bitmap, - targetSize: IntSize, -): Bitmap { - val targetWidth = targetSize.width.coerceAtLeast(minimumValue = 1) - val targetHeight = targetSize.height.coerceAtLeast(minimumValue = 1) - - if (bitmap.width <= targetWidth && bitmap.height <= targetHeight) { - return bitmap - } - - val widthScale = targetWidth.toFloat() / bitmap.width.toFloat() - val heightScale = targetHeight.toFloat() / bitmap.height.toFloat() - val scale = minOf(widthScale, heightScale) - - return bitmap.scale( - width = (bitmap.width * scale).toInt().coerceAtLeast(minimumValue = 1), - height = (bitmap.height * scale).toInt().coerceAtLeast(minimumValue = 1), - ) -} - -private fun IntSize.sanitized(): IntSize { - if (width >= 1 && height >= 1) { - return this - } - - return IntSize( - width = width.coerceAtLeast(minimumValue = 1), - height = height.coerceAtLeast(minimumValue = 1), - ) -} diff --git a/src/com/android/messaging/ui/conversation/v2/mediapicker/component/ConversationMediaThumbnailBitmapLoader.kt b/src/com/android/messaging/ui/conversation/v2/mediapicker/component/ConversationMediaThumbnailBitmapLoader.kt new file mode 100644 index 00000000..30495d98 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/v2/mediapicker/component/ConversationMediaThumbnailBitmapLoader.kt @@ -0,0 +1,306 @@ +package com.android.messaging.ui.conversation.v2.mediapicker.component + +import android.content.ContentResolver +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.BitmapFactory.Options +import android.net.Uri +import android.util.Size +import androidx.compose.ui.unit.IntSize +import androidx.core.graphics.scale +import com.android.messaging.util.ContentType +import com.android.messaging.util.MediaMetadataRetrieverWrapper +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +private const val FIRST_PASS_DIVISOR = 12 +private const val FIRST_PASS_MAXIMUM_SIZE = 24 +private const val FIRST_PASS_MINIMUM_SIZE = 12 +private const val SECOND_PASS_DIVISOR = 3 +private const val SECOND_PASS_MAXIMUM_SIZE = 48 +private const val SECOND_PASS_MINIMUM_SIZE = 24 + +internal suspend fun loadConversationMediaThumbnailBitmap( + contentResolver: ContentResolver, + contentUri: Uri, + contentType: String, + size: IntSize, + softenBitmap: Boolean, +): Bitmap? { + return withContext(context = Dispatchers.IO) { + val rawBitmap = loadPlatformThumbnail( + contentResolver = contentResolver, + contentUri = contentUri, + size = size, + ) ?: loadFallbackBitmap( + contentResolver = contentResolver, + contentUri = contentUri, + contentType = contentType, + size = size, + ) + + maybeSoftenBitmap( + bitmap = rawBitmap, + outputSize = size, + softenBitmap = softenBitmap, + ) + } +} + +private fun loadPlatformThumbnail( + contentResolver: ContentResolver, + contentUri: Uri, + size: IntSize, +): Bitmap? { + return runCatching { + contentResolver.loadThumbnail( + contentUri, + Size(size.width, size.height), + null, + ) + }.getOrNull() +} + +private fun loadFallbackBitmap( + contentResolver: ContentResolver, + contentUri: Uri, + contentType: String, + size: IntSize, +): Bitmap? { + return when { + ContentType.isImageType(contentType) -> { + loadImageBitmapFallback( + contentResolver = contentResolver, + contentUri = contentUri, + size = size, + ) + } + + ContentType.isVideoType(contentType) -> { + loadVideoFrameFallback( + contentUri = contentUri, + size = size, + ) + } + + else -> null + } +} + +private fun loadImageBitmapFallback( + contentResolver: ContentResolver, + contentUri: Uri, + size: IntSize, +): Bitmap? { + return runCatching { + val decodeBoundsOptions = Options().apply { + inJustDecodeBounds = true + } + + contentResolver + .openInputStream(contentUri) + ?.use { inputStream -> + BitmapFactory.decodeStream(inputStream, null, decodeBoundsOptions) + } + + val decodeBitmapOptions = Options().apply { + inSampleSize = calculateBitmapSampleSize( + sourceWidth = decodeBoundsOptions.outWidth, + sourceHeight = decodeBoundsOptions.outHeight, + targetSize = size, + ) + } + + contentResolver + .openInputStream(contentUri) + ?.use { inputStream -> + BitmapFactory.decodeStream(inputStream, null, decodeBitmapOptions) + } + }.getOrNull() +} + +private fun loadVideoFrameFallback( + contentUri: Uri, + size: IntSize, +): Bitmap? { + val retriever = MediaMetadataRetrieverWrapper() + + return try { + runCatching { + retriever.setDataSource(contentUri) + retriever.frameAtTime?.let { bitmap -> + scaleBitmapDownIfNeeded( + bitmap = bitmap, + targetSize = size, + ) + } + }.getOrNull() + } finally { + retriever.release() + } +} + +private fun maybeSoftenBitmap( + bitmap: Bitmap?, + outputSize: IntSize, + softenBitmap: Boolean, +): Bitmap? { + return when { + bitmap == null -> null + !softenBitmap -> bitmap + + else -> { + createSoftenedBitmap( + sourceBitmap = bitmap, + outputSize = outputSize, + ) + } + } +} + +private fun createSoftenedBitmap( + sourceBitmap: Bitmap, + outputSize: IntSize, +): Bitmap { + val sanitizedOutputSize = outputSize.sanitized() + val targetWidth = sanitizedOutputSize.width + val targetHeight = sanitizedOutputSize.height + val centerCroppedBitmap = createCenterCroppedBitmap( + sourceBitmap = sourceBitmap, + targetSize = sanitizedOutputSize, + ) + + // Multi-pass downscaling keeps softened placeholders smooth without introducing blur kernels + val firstPassWidth = (targetWidth / FIRST_PASS_DIVISOR).coerceIn( + minimumValue = FIRST_PASS_MINIMUM_SIZE, + maximumValue = FIRST_PASS_MAXIMUM_SIZE, + ) + val firstPassHeight = (targetHeight / FIRST_PASS_DIVISOR).coerceIn( + minimumValue = FIRST_PASS_MINIMUM_SIZE, + maximumValue = FIRST_PASS_MAXIMUM_SIZE, + ) + val secondPassWidth = (targetWidth / SECOND_PASS_DIVISOR).coerceIn( + minimumValue = SECOND_PASS_MINIMUM_SIZE, + maximumValue = SECOND_PASS_MAXIMUM_SIZE, + ) + val secondPassHeight = (targetHeight / SECOND_PASS_DIVISOR).coerceIn( + minimumValue = SECOND_PASS_MINIMUM_SIZE, + maximumValue = SECOND_PASS_MAXIMUM_SIZE, + ) + + val firstPassBitmap = centerCroppedBitmap.scale(firstPassWidth, firstPassHeight) + val secondPassBitmap = firstPassBitmap.scale(secondPassWidth, secondPassHeight) + + return secondPassBitmap.scale(targetWidth, targetHeight) +} + +private fun createCenterCroppedBitmap( + sourceBitmap: Bitmap, + targetSize: IntSize, +): Bitmap { + val sanitizedTargetSize = targetSize.sanitized() + val targetAspectRatio = + sanitizedTargetSize.width.toFloat() / sanitizedTargetSize.height.toFloat() + val sourceAspectRatio = sourceBitmap.width.toFloat() / sourceBitmap.height.toFloat() + + val cropWidth: Int + val cropHeight: Int + + when { + sourceAspectRatio > targetAspectRatio -> { + cropHeight = sourceBitmap.height + cropWidth = (cropHeight * targetAspectRatio).toInt() + } + + else -> { + cropWidth = sourceBitmap.width + cropHeight = (cropWidth / targetAspectRatio).toInt() + } + } + + val left = (sourceBitmap.width - cropWidth) / 2 + val top = (sourceBitmap.height - cropHeight) / 2 + + return Bitmap.createBitmap( + sourceBitmap, + left, + top, + cropWidth.coerceAtLeast(minimumValue = 1), + cropHeight.coerceAtLeast(minimumValue = 1), + ) +} + +private fun calculateBitmapSampleSize( + sourceWidth: Int, + sourceHeight: Int, + targetSize: IntSize, +): Int { + if (sourceWidth <= 0 || sourceHeight <= 0) { + return 1 + } + + var sampleSize = 1 + val targetWidth = targetSize.width.coerceAtLeast(minimumValue = 1) + val targetHeight = targetSize.height.coerceAtLeast(minimumValue = 1) + + while ( + canDoubleBitmapSampleSize( + sourceWidth = sourceWidth, + sourceHeight = sourceHeight, + sampleSize = sampleSize, + targetWidth = targetWidth, + targetHeight = targetHeight, + ) + ) { + sampleSize *= 2 + } + + return sampleSize +} + +private fun canDoubleBitmapSampleSize( + sourceWidth: Int, + sourceHeight: Int, + sampleSize: Int, + targetWidth: Int, + targetHeight: Int, +): Boolean { + val doubledSampleSize = sampleSize * 2 + val doubledDecodedWidth = sourceWidth / doubledSampleSize + val doubledDecodedHeight = sourceHeight / doubledSampleSize + + return doubledDecodedWidth >= targetWidth && + doubledDecodedHeight >= targetHeight +} + +private fun scaleBitmapDownIfNeeded( + bitmap: Bitmap, + targetSize: IntSize, +): Bitmap { + val targetWidth = targetSize.width.coerceAtLeast(minimumValue = 1) + val targetHeight = targetSize.height.coerceAtLeast(minimumValue = 1) + + if (bitmap.width <= targetWidth && bitmap.height <= targetHeight) { + return bitmap + } + + val widthScale = targetWidth.toFloat() / bitmap.width.toFloat() + val heightScale = targetHeight.toFloat() / bitmap.height.toFloat() + val scale = minOf(widthScale, heightScale) + + return bitmap.scale( + width = (bitmap.width * scale).toInt().coerceAtLeast(minimumValue = 1), + height = (bitmap.height * scale).toInt().coerceAtLeast(minimumValue = 1), + ) +} + +internal fun IntSize.sanitized(): IntSize { + if (width >= 1 && height >= 1) { + return this + } + + return IntSize( + width = width.coerceAtLeast(minimumValue = 1), + height = height.coerceAtLeast(minimumValue = 1), + ) +} diff --git a/src/com/android/messaging/ui/conversation/v2/mediapicker/component/capture/ConversationMediaCaptureShutterButton.kt b/src/com/android/messaging/ui/conversation/v2/mediapicker/component/capture/ConversationMediaCaptureShutterButton.kt index 04c96af5..2ff4a8e7 100644 --- a/src/com/android/messaging/ui/conversation/v2/mediapicker/component/capture/ConversationMediaCaptureShutterButton.kt +++ b/src/com/android/messaging/ui/conversation/v2/mediapicker/component/capture/ConversationMediaCaptureShutterButton.kt @@ -1,7 +1,6 @@ package com.android.messaging.ui.conversation.v2.mediapicker.component.capture import androidx.compose.animation.animateColor -import androidx.compose.animation.core.Transition import androidx.compose.animation.core.animateDp import androidx.compose.animation.core.animateFloat import androidx.compose.animation.core.spring @@ -12,7 +11,6 @@ import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape @@ -20,7 +18,6 @@ import androidx.compose.material3.ColorScheme import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.runtime.Composable -import androidx.compose.runtime.State import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -32,7 +29,6 @@ import com.android.messaging.ui.conversation.v2.mediapicker.ConversationCaptureM import com.android.messaging.ui.conversation.v2.mediapicker.component.capture.ConversationMediaCaptureShutterPhase.Photo import com.android.messaging.ui.conversation.v2.mediapicker.component.capture.ConversationMediaCaptureShutterPhase.VideoIdle import com.android.messaging.ui.conversation.v2.mediapicker.component.capture.ConversationMediaCaptureShutterPhase.VideoRecording -import com.android.messaging.ui.core.AppTheme private val PICKER_SHUTTER_BORDER_WIDTH = 3.dp private val PICKER_SHUTTER_OUTER_SIZE = 78.dp @@ -53,6 +49,19 @@ private enum class ConversationMediaCaptureShutterPhase { VideoRecording, } +private data class ConversationMediaCaptureShutterVisualState( + val innerShutterColor: Color, + val innerShutterSize: Dp, + val outerContainerColor: Color, + val outerScale: Float, + val recordingStopAlpha: Float, + val recordingStopBackgroundColor: Color, + val recordingStopScale: Float, + val videoCenterDotAlpha: Float, + val videoCenterDotColor: Color, + val videoCenterDotScale: Float, +) + @Composable internal fun ConversationMediaCaptureShutterButton( captureMode: ConversationCaptureMode, @@ -81,38 +90,30 @@ private fun ConversationMediaCaptureShutterButtonAnimatedContent( onClick: () -> Unit, shutterPhase: ConversationMediaCaptureShutterPhase, ) { - val transition = updateTransition( - targetState = shutterPhase, - label = "picker_shutter_phase", + val visualState = animateConversationMediaCaptureShutterVisualState( + colorScheme = colorScheme, + shutterPhase = shutterPhase, ) - val outerContainerColor by transition.animateOuterContainerColor(colorScheme) - val innerShutterColor by transition.animateInnerShutterColor(colorScheme) - val innerShutterSize by transition.animateInnerShutterSize() - val outerScale by transition.animateOuterScale() - val videoCenterDotAlpha by transition.animateVideoCenterDotAlpha() - val videoCenterDotScale by transition.animateVideoCenterDotScale() - val recordingStopAlpha by transition.animateRecordingStopAlpha() - val recordingStopScale by transition.animateRecordingStopScale() ConversationMediaCaptureShutterButtonShell( borderColor = colorScheme.inverseOnSurface, isEnabled = isEnabled, onClick = onClick, - outerContainerColor = outerContainerColor, - outerScale = outerScale, + outerContainerColor = visualState.outerContainerColor, + outerScale = visualState.outerScale, ) { ConversationMediaCaptureShutterInnerDisc( - innerShutterColor = innerShutterColor, - innerShutterSize = innerShutterSize, + innerShutterColor = visualState.innerShutterColor, + innerShutterSize = visualState.innerShutterSize, ) { if (shutterPhase != Photo) { ConversationMediaCaptureVideoOverlay( - recordingStopAlpha = recordingStopAlpha, - recordingStopBackgroundColor = colorScheme.error.copy(alpha = 0.3f), - recordingStopScale = recordingStopScale, - videoCenterDotAlpha = videoCenterDotAlpha, - videoCenterDotColor = colorScheme.inverseOnSurface, - videoCenterDotScale = videoCenterDotScale, + recordingStopAlpha = visualState.recordingStopAlpha, + recordingStopBackgroundColor = visualState.recordingStopBackgroundColor, + recordingStopScale = visualState.recordingStopScale, + videoCenterDotAlpha = visualState.videoCenterDotAlpha, + videoCenterDotColor = visualState.videoCenterDotColor, + videoCenterDotScale = visualState.videoCenterDotScale, ) } } @@ -120,25 +121,24 @@ private fun ConversationMediaCaptureShutterButtonAnimatedContent( } @Composable -private fun Transition.animateInnerShutterColor( +private fun animateConversationMediaCaptureShutterVisualState( colorScheme: ColorScheme, -): State { - return animateColor( + shutterPhase: ConversationMediaCaptureShutterPhase, +): ConversationMediaCaptureShutterVisualState { + val transition = updateTransition( + targetState = shutterPhase, + label = "picker_shutter_phase", + ) + val innerShutterColor by transition.animateColor( transitionSpec = { PICKER_SHUTTER_COLOR_ANIMATION_SPEC }, label = "picker_shutter_inner_color", targetValueByState = { phase -> - phase.resolveInnerShutterColor( - colorScheme = colorScheme, - ) + phase.toVisualState(colorScheme = colorScheme).innerShutterColor }, ) -} - -@Composable -private fun Transition.animateInnerShutterSize(): State { - return animateDp( + val innerShutterSize by transition.animateDp( transitionSpec = { spring( dampingRatio = PICKER_SHUTTER_STATE_TRANSITION_SPRING_DAMPING_RATIO, @@ -147,95 +147,77 @@ private fun Transition.animateInnerShutter }, label = "picker_shutter_inner_size", targetValueByState = { phase -> - phase.resolveInnerShutterSize() + phase.toVisualState(colorScheme = colorScheme).innerShutterSize }, ) -} - -@Composable -private fun Transition.animateOuterContainerColor( - colorScheme: ColorScheme, -): State { - return animateColor( + val outerContainerColor by transition.animateColor( transitionSpec = { PICKER_SHUTTER_COLOR_ANIMATION_SPEC }, label = "picker_shutter_outer_color", targetValueByState = { phase -> - phase.resolveOuterContainerColor( - colorScheme = colorScheme, - ) + phase.toVisualState(colorScheme = colorScheme).outerContainerColor }, ) -} - -@Composable -private fun Transition.animateOuterScale(): State { - return animateFloat( + val outerScale by transition.animateFloat( transitionSpec = { PICKER_SHUTTER_FLOAT_SPRING_ANIMATION_SPEC }, label = "picker_shutter_outer_scale", targetValueByState = { phase -> - phase.resolveOuterScale() + phase.toVisualState(colorScheme = colorScheme).outerScale }, ) -} - -@Composable -private fun Transition.animateRecordingStopAlpha(): - State { - return animateFloat( + val recordingStopAlpha by transition.animateFloat( transitionSpec = { tween(durationMillis = 130) }, label = "picker_shutter_recording_stop_alpha", targetValueByState = { phase -> - phase.resolveRecordingStopAlpha() + phase.toVisualState(colorScheme = colorScheme).recordingStopAlpha }, ) -} - -@Composable -private fun Transition.animateRecordingStopScale(): - State { - return animateFloat( + val recordingStopScale by transition.animateFloat( transitionSpec = { PICKER_SHUTTER_FLOAT_SPRING_ANIMATION_SPEC }, label = "picker_shutter_recording_stop_scale", targetValueByState = { phase -> - phase.resolveRecordingStopScale() + phase.toVisualState(colorScheme = colorScheme).recordingStopScale }, ) -} - -@Composable -private fun Transition.animateVideoCenterDotAlpha(): - State { - return animateFloat( + val videoCenterDotAlpha by transition.animateFloat( transitionSpec = { tween(durationMillis = 110) }, label = "picker_shutter_video_center_dot_alpha", targetValueByState = { phase -> - phase.resolveVideoCenterDotAlpha() + phase.toVisualState(colorScheme = colorScheme).videoCenterDotAlpha }, ) -} - -@Composable -private fun Transition.animateVideoCenterDotScale(): - State { - return animateFloat( + val videoCenterDotScale by transition.animateFloat( transitionSpec = { PICKER_SHUTTER_FLOAT_SPRING_ANIMATION_SPEC }, label = "picker_shutter_video_center_dot_scale", targetValueByState = { phase -> - phase.resolveVideoCenterDotScale() + phase.toVisualState(colorScheme = colorScheme).videoCenterDotScale }, ) + val targetVisualState = shutterPhase.toVisualState(colorScheme = colorScheme) + + return ConversationMediaCaptureShutterVisualState( + innerShutterColor = innerShutterColor, + innerShutterSize = innerShutterSize, + outerContainerColor = outerContainerColor, + outerScale = outerScale, + recordingStopAlpha = recordingStopAlpha, + recordingStopBackgroundColor = targetVisualState.recordingStopBackgroundColor, + recordingStopScale = recordingStopScale, + videoCenterDotAlpha = videoCenterDotAlpha, + videoCenterDotColor = targetVisualState.videoCenterDotColor, + videoCenterDotScale = videoCenterDotScale, + ) } @Composable @@ -365,107 +347,47 @@ private fun resolveConversationMediaCaptureShutterPhase( } } -private fun ConversationMediaCaptureShutterPhase.resolveInnerShutterColor( - colorScheme: ColorScheme, -): Color { - return when (this) { - Photo -> colorScheme.inverseOnSurface - VideoIdle -> colorScheme.scrim.copy(alpha = 0.5f) - VideoRecording -> colorScheme.errorContainer - } -} - -private fun ConversationMediaCaptureShutterPhase.resolveInnerShutterSize(): Dp { - return when (this) { - Photo -> PICKER_SHUTTER_PHOTO_INNER_SIZE - - VideoIdle, - VideoRecording, - -> PICKER_SHUTTER_FULL_INNER_SIZE - } -} - -private fun ConversationMediaCaptureShutterPhase.resolveOuterContainerColor( +private fun ConversationMediaCaptureShutterPhase.toVisualState( colorScheme: ColorScheme, -): Color { - return when (this) { - Photo -> colorScheme.scrim.copy(alpha = 0.2f) - - VideoIdle, - VideoRecording, - -> Color.Transparent - } -} - -private fun ConversationMediaCaptureShutterPhase.resolveOuterScale(): Float { - return when (this) { - Photo, - VideoIdle, - -> 1f - - VideoRecording -> 0.97f - } -} - -private fun ConversationMediaCaptureShutterPhase.resolveRecordingStopAlpha(): Float { - return when (this) { - Photo, - VideoIdle, - -> 0f - - VideoRecording -> 1f - } -} - -private fun ConversationMediaCaptureShutterPhase.resolveRecordingStopScale(): Float { - return when (this) { - Photo, - VideoIdle, - -> 0.8f - - VideoRecording -> 1f - } -} - -private fun ConversationMediaCaptureShutterPhase.resolveVideoCenterDotAlpha(): Float { - return when (this) { - Photo, - VideoRecording, - -> 0f - - VideoIdle -> 1f - } -} - -private fun ConversationMediaCaptureShutterPhase.resolveVideoCenterDotScale(): Float { +): ConversationMediaCaptureShutterVisualState { return when (this) { - Photo, - VideoRecording, - -> 0.72f + Photo -> ConversationMediaCaptureShutterVisualState( + innerShutterColor = colorScheme.inverseOnSurface, + innerShutterSize = PICKER_SHUTTER_PHOTO_INNER_SIZE, + outerContainerColor = colorScheme.scrim.copy(alpha = 0.2f), + outerScale = 1f, + recordingStopAlpha = 0f, + recordingStopBackgroundColor = colorScheme.error.copy(alpha = 0.3f), + recordingStopScale = 0.8f, + videoCenterDotAlpha = 0f, + videoCenterDotColor = colorScheme.inverseOnSurface, + videoCenterDotScale = 0.72f, + ) - VideoIdle -> 1f - } -} + VideoIdle -> ConversationMediaCaptureShutterVisualState( + innerShutterColor = colorScheme.scrim.copy(alpha = 0.5f), + innerShutterSize = PICKER_SHUTTER_FULL_INNER_SIZE, + outerContainerColor = Color.Transparent, + outerScale = 1f, + recordingStopAlpha = 0f, + recordingStopBackgroundColor = colorScheme.error.copy(alpha = 0.3f), + recordingStopScale = 0.8f, + videoCenterDotAlpha = 1f, + videoCenterDotColor = colorScheme.inverseOnSurface, + videoCenterDotScale = 1f, + ) -@Composable -private fun ConversationMediaCaptureShutterButtonPreviewContainer( - captureMode: ConversationCaptureMode, - isPhotoCaptureInProgress: Boolean = false, - isRecording: Boolean = false, -) { - AppTheme { - Surface(color = Color.Black.copy(alpha = 0.5f)) { - Box( - modifier = Modifier.padding(16.dp), - contentAlignment = Alignment.Center, - ) { - ConversationMediaCaptureShutterButton( - captureMode = captureMode, - isPhotoCaptureInProgress = isPhotoCaptureInProgress, - isRecording = isRecording, - onClick = {}, - ) - } - } + VideoRecording -> ConversationMediaCaptureShutterVisualState( + innerShutterColor = colorScheme.errorContainer, + innerShutterSize = PICKER_SHUTTER_FULL_INNER_SIZE, + outerContainerColor = Color.Transparent, + outerScale = 0.97f, + recordingStopAlpha = 1f, + recordingStopBackgroundColor = colorScheme.error.copy(alpha = 0.3f), + recordingStopScale = 1f, + videoCenterDotAlpha = 0f, + videoCenterDotColor = colorScheme.inverseOnSurface, + videoCenterDotScale = 0.72f, + ) } } diff --git a/src/com/android/messaging/ui/conversation/v2/mediapicker/component/review/ConversationMediaPickerReview.kt b/src/com/android/messaging/ui/conversation/v2/mediapicker/component/review/ConversationMediaPickerReview.kt index 0d82451b..b7ccda8e 100644 --- a/src/com/android/messaging/ui/conversation/v2/mediapicker/component/review/ConversationMediaPickerReview.kt +++ b/src/com/android/messaging/ui/conversation/v2/mediapicker/component/review/ConversationMediaPickerReview.kt @@ -83,7 +83,7 @@ internal fun ConversationMediaReviewScene( initiallyReviewedContentUri = initiallyReviewedContentUri, reviewRequestSequence = reviewRequestSequence, photoPickerSourceContentUriByAttachmentContentUri = - photoPickerSourceContentUriByAttachmentContentUri, + photoPickerSourceContentUriByAttachmentContentUri, ) val imeBottomPadding = WindowInsets.ime.asPaddingValues().calculateBottomPadding() diff --git a/src/com/android/messaging/ui/conversation/v2/mediapicker/component/review/ConversationMediaReviewPagerState.kt b/src/com/android/messaging/ui/conversation/v2/mediapicker/component/review/ConversationMediaReviewPagerState.kt index 63f4e1ea..c3059292 100644 --- a/src/com/android/messaging/ui/conversation/v2/mediapicker/component/review/ConversationMediaReviewPagerState.kt +++ b/src/com/android/messaging/ui/conversation/v2/mediapicker/component/review/ConversationMediaReviewPagerState.kt @@ -36,7 +36,7 @@ internal fun rememberConversationMediaReviewPagerState( attachments = attachments, initiallyReviewedContentUri = initiallyReviewedContentUri, photoPickerSourceContentUriByAttachmentContentUri = - photoPickerSourceContentUriByAttachmentContentUri, + photoPickerSourceContentUriByAttachmentContentUri, ) val pagerState = rememberPagerState( @@ -67,7 +67,7 @@ internal fun rememberConversationMediaReviewPagerState( initiallyReviewedContentUri = initiallyReviewedContentUri, reviewRequestSequence = reviewRequestSequence, photoPickerSourceContentUriByAttachmentContentUri = - photoPickerSourceContentUriByAttachmentContentUri, + photoPickerSourceContentUriByAttachmentContentUri, pagerState = pagerState, ) } @@ -108,7 +108,7 @@ private class ConversationMediaReviewPagerCoordinator( attachments = attachments, requestedReviewContentUri = pendingRequestedReviewContentUri, photoPickerSourceContentUriByAttachmentContentUri = - photoPickerSourceContentUriByAttachmentContentUri, + photoPickerSourceContentUriByAttachmentContentUri, ) val targetPage = requestedAttachmentPage ?: clampAttachmentPage( @@ -169,7 +169,7 @@ private fun resolveInitialReviewPage( .indexOfFirst { attachment -> attachment.contentUri == initiallyReviewedContentUri || photoPickerSourceContentUriByAttachmentContentUri[attachment.contentUri] == - initiallyReviewedContentUri + initiallyReviewedContentUri } .takeIf { it >= 0 } ?: attachments.lastIndex diff --git a/src/com/android/messaging/ui/conversation/v2/mediapicker/repository/ConversationAttachmentRepository.kt b/src/com/android/messaging/ui/conversation/v2/mediapicker/repository/ConversationAttachmentRepository.kt index 0d4a50ea..735af66f 100644 --- a/src/com/android/messaging/ui/conversation/v2/mediapicker/repository/ConversationAttachmentRepository.kt +++ b/src/com/android/messaging/ui/conversation/v2/mediapicker/repository/ConversationAttachmentRepository.kt @@ -21,14 +21,14 @@ import com.android.messaging.util.ContentType import com.android.messaging.util.LogUtil import com.android.messaging.util.core.extension.typedFlow import com.android.messaging.util.core.extension.unitFlow +import java.io.IOException +import javax.inject.Inject import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOn -import java.io.IOException -import javax.inject.Inject internal interface ConversationAttachmentRepository { fun createDraftAttachmentsFromPhotoPicker( diff --git a/src/com/android/messaging/ui/conversation/v2/messages/ui/message/ConversationMessage.kt b/src/com/android/messaging/ui/conversation/v2/messages/ui/message/ConversationMessage.kt index c05adcf3..cf36f46b 100644 --- a/src/com/android/messaging/ui/conversation/v2/messages/ui/message/ConversationMessage.kt +++ b/src/com/android/messaging/ui/conversation/v2/messages/ui/message/ConversationMessage.kt @@ -2,30 +2,20 @@ package com.android.messaging.ui.conversation.v2.messages.ui.message import android.content.Context import android.text.format.DateUtils -import androidx.compose.animation.animateColorAsState -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.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable -import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext @@ -34,8 +24,6 @@ import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.selected import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.android.messaging.R @@ -43,18 +31,11 @@ import com.android.messaging.sms.cleanseMmsSubject import com.android.messaging.ui.conversation.v2.messages.model.message.ConversationMessageContent import com.android.messaging.ui.conversation.v2.messages.model.message.ConversationMessageUiModel import com.android.messaging.ui.conversation.v2.messages.model.message.ConversationMessageUiModel.Status -import com.android.messaging.ui.conversation.v2.messages.ui.attachment.ConversationMessageAttachments -import com.android.messaging.ui.conversation.v2.messages.ui.text.ConversationMessageText private const val MESSAGE_BUBBLE_MAX_WIDTH_DP = 360 private const val MESSAGE_BUBBLE_WIDTH_FRACTION = 0.8f private const val MESSAGE_BUBBLE_CORNER_RADIUS_DP = 24 private const val MESSAGE_BUBBLE_CONNECTED_CORNER_RADIUS_DP = 6 -private val MESSAGE_BUBBLE_MEDIA_SECTION_SPACING = 8.dp -private val MESSAGE_BUBBLE_MEDIA_TEXT_PADDING = 12.dp -private val MESSAGE_BUBBLE_TEXT_HORIZONTAL_PADDING = 16.dp -private val MESSAGE_BUBBLE_TEXT_VERTICAL_PADDING = 12.dp -private const val MESSAGE_SELECTION_MEDIA_OVERLAY_ALPHA = 0.2f @Composable internal fun ConversationMessage( @@ -99,7 +80,7 @@ internal fun ConversationMessage( } @Immutable -private data class ConversationMessageLayout( +internal data class ConversationMessageLayout( val bubbleShape: RoundedCornerShape, val bubbleLayoutMode: ConversationMessageBubbleLayoutMode, val content: ConversationMessageContent, @@ -107,7 +88,7 @@ private data class ConversationMessageLayout( val showSender: Boolean, ) -private enum class ConversationMessageBubbleLayoutMode { +internal enum class ConversationMessageBubbleLayoutMode { AttachmentOnlyWithoutSurface, AttachmentsInSurface, TextInSurface, @@ -307,348 +288,6 @@ private fun ConversationMessageContent( } } -@Composable -private fun ConversationMessageBubble( - modifier: Modifier = Modifier, - message: ConversationMessageUiModel, - isSelected: Boolean, - isSelectionMode: Boolean, - layout: ConversationMessageLayout, - maxBubbleWidth: Dp, - onAttachmentClick: (contentType: String, contentUri: String) -> Unit, - onExternalUriClick: (String) -> Unit, - onMessageLongClick: () -> Unit, -) { - when (layout.bubbleLayoutMode) { - ConversationMessageBubbleLayoutMode.AttachmentOnlyWithoutSurface -> { - ConversationMessageAttachmentOnlyContainer( - modifier = Modifier - .widthIn(max = maxBubbleWidth) - .then(other = modifier), - bubbleShape = layout.bubbleShape, - message = message, - isSelected = isSelected, - ) { - ConversationMessageAttachmentBubbleContent( - modifier = Modifier - .fillMaxWidth(), - content = layout.content, - message = message, - isSelected = isSelected, - isSelectionMode = isSelectionMode, - senderDisplayName = message.senderDisplayName, - showSender = layout.showSender, - onAttachmentClick = onAttachmentClick, - onExternalUriClick = onExternalUriClick, - onMessageLongClick = onMessageLongClick, - ) - } - } - - ConversationMessageBubbleLayoutMode.AttachmentsInSurface -> { - ConversationMessageBubbleSurface( - modifier = Modifier - .widthIn(max = maxBubbleWidth) - .then(other = modifier), - isSelected = isSelected, - message = message, - layout = layout, - ) { - ConversationMessageAttachmentBubbleContent( - content = layout.content, - message = message, - isSelected = isSelected, - isSelectionMode = isSelectionMode, - senderDisplayName = message.senderDisplayName, - showSender = layout.showSender, - onAttachmentClick = onAttachmentClick, - onExternalUriClick = onExternalUriClick, - onMessageLongClick = onMessageLongClick, - ) - } - } - - ConversationMessageBubbleLayoutMode.TextInSurface -> { - ConversationMessageBubbleSurface( - modifier = Modifier - .widthIn(max = maxBubbleWidth) - .then(other = modifier), - isSelected = isSelected, - message = message, - layout = layout, - ) { - ConversationMessageTextBubbleContent( - content = layout.content, - message = message, - isSelected = isSelected, - isSelectionMode = isSelectionMode, - senderDisplayName = message.senderDisplayName, - showSender = layout.showSender, - onAttachmentClick = onAttachmentClick, - onExternalUriClick = onExternalUriClick, - onMessageLongClick = onMessageLongClick, - ) - } - } - } -} - -@Composable -private fun ConversationMessageBubbleSurface( - modifier: Modifier = Modifier, - isSelected: Boolean, - message: ConversationMessageUiModel, - layout: ConversationMessageLayout, - bubbleContent: @Composable () -> Unit, -) { - Surface( - color = messageBubbleColor( - message = message, - isSelected = isSelected, - ), - contentColor = messageBubbleContentColor( - message = message, - isSelected = isSelected, - ), - shape = layout.bubbleShape, - modifier = modifier, - ) { - bubbleContent() - } -} - -@Composable -private fun ConversationMessageAttachmentOnlyContainer( - modifier: Modifier = Modifier, - bubbleShape: RoundedCornerShape, - message: ConversationMessageUiModel, - isSelected: Boolean, - content: @Composable () -> Unit, -) { - val overlayColor by animateColorAsState( - targetValue = when { - isSelected -> { - messageBubbleColor( - message = message, - isSelected = true, - ).copy(alpha = MESSAGE_SELECTION_MEDIA_OVERLAY_ALPHA) - } - - else -> Color.Transparent - }, - label = "conversationMessageSelectionOverlayColor", - ) - - Box( - modifier = modifier.clip(shape = bubbleShape), - ) { - content() - - if (overlayColor != Color.Transparent) { - Box( - modifier = Modifier - .fillMaxSize() - .clip(shape = bubbleShape) - .background(color = overlayColor), - ) - } - } -} - -@Composable -private fun ConversationMessageTextBubbleContent( - content: ConversationMessageContent, - message: ConversationMessageUiModel, - isSelected: Boolean, - isSelectionMode: Boolean, - senderDisplayName: String?, - showSender: Boolean, - onAttachmentClick: (contentType: String, contentUri: String) -> Unit, - onExternalUriClick: (String) -> Unit, - onMessageLongClick: () -> Unit, -) { - Column( - modifier = Modifier.padding( - horizontal = MESSAGE_BUBBLE_TEXT_HORIZONTAL_PADDING, - vertical = MESSAGE_BUBBLE_TEXT_VERTICAL_PADDING, - ), - verticalArrangement = Arrangement.spacedBy(space = 8.dp), - ) { - ConversationMessageSender( - color = messageSenderColor( - message = message, - isSelected = isSelected, - ), - senderDisplayName = senderDisplayName, - showSender = showSender, - ) - - ConversationMessageBody( - content = content, - isIncoming = message.isIncoming, - isSelectionMode = isSelectionMode, - onAttachmentClick = onAttachmentClick, - onExternalUriClick = onExternalUriClick, - onMessageLongClick = onMessageLongClick, - ) - } -} - -@Composable -private fun ConversationMessageAttachmentBubbleContent( - modifier: Modifier = Modifier, - content: ConversationMessageContent, - message: ConversationMessageUiModel, - isSelected: Boolean, - isSelectionMode: Boolean, - senderDisplayName: String?, - showSender: Boolean, - onAttachmentClick: (contentType: String, contentUri: String) -> Unit, - onExternalUriClick: (String) -> Unit, - onMessageLongClick: () -> Unit, -) { - val hasHeader = showSender || !content.subjectText.isNullOrBlank() - val hasBodyText = !content.bodyText.isNullOrBlank() - - Column( - modifier = modifier.fillMaxWidth(), - ) { - ConversationMessageSender( - modifier = Modifier.padding( - start = MESSAGE_BUBBLE_MEDIA_TEXT_PADDING, - top = MESSAGE_BUBBLE_MEDIA_TEXT_PADDING, - end = MESSAGE_BUBBLE_MEDIA_TEXT_PADDING, - bottom = when { - content.subjectText.isNullOrBlank() -> 6.dp - else -> MESSAGE_BUBBLE_MEDIA_SECTION_SPACING - }, - ), - color = messageSenderColor( - message = message, - isSelected = isSelected, - ), - senderDisplayName = senderDisplayName, - showSender = showSender, - ) - - content.subjectText?.let { subjectText -> - Text( - modifier = Modifier.padding( - start = MESSAGE_BUBBLE_MEDIA_TEXT_PADDING, - end = MESSAGE_BUBBLE_MEDIA_TEXT_PADDING, - bottom = MESSAGE_BUBBLE_MEDIA_SECTION_SPACING, - ), - text = subjectText, - style = MaterialTheme.typography.titleSmall, - ) - } - - ConversationMessageAttachments( - attachmentSections = content.attachmentSections, - hasTextAboveVisualAttachments = hasHeader, - hasTextBelowVisualAttachments = hasBodyText, - isIncoming = message.isIncoming, - isSelectionMode = isSelectionMode, - useStandaloneAudioAttachmentBg = false, - onAttachmentClick = onAttachmentClick, - onExternalUriClick = onExternalUriClick, - onMessageLongClick = onMessageLongClick, - ) - - content.bodyText?.let { bodyText -> - ConversationMessageText( - modifier = Modifier.padding( - start = MESSAGE_BUBBLE_MEDIA_TEXT_PADDING, - top = MESSAGE_BUBBLE_MEDIA_SECTION_SPACING, - end = MESSAGE_BUBBLE_MEDIA_TEXT_PADDING, - bottom = MESSAGE_BUBBLE_MEDIA_TEXT_PADDING, - ), - text = bodyText, - style = MaterialTheme.typography.bodyLarge, - onExternalUriClick = onExternalUriClick, - ) - } - } -} - -@Composable -private fun ConversationMessageBody( - content: ConversationMessageContent, - isIncoming: Boolean, - isSelectionMode: Boolean, - onAttachmentClick: (contentType: String, contentUri: String) -> Unit, - onExternalUriClick: (String) -> Unit, - onMessageLongClick: () -> Unit, -) { - content.subjectText?.let { subjectText -> - Text( - text = subjectText, - style = MaterialTheme.typography.titleSmall, - ) - } - - ConversationMessageAttachments( - attachmentSections = content.attachmentSections, - hasTextAboveVisualAttachments = false, - hasTextBelowVisualAttachments = false, - isIncoming = isIncoming, - isSelectionMode = isSelectionMode, - useStandaloneAudioAttachmentBg = true, - onAttachmentClick = onAttachmentClick, - onExternalUriClick = onExternalUriClick, - onMessageLongClick = onMessageLongClick, - ) - - content.bodyText?.let { bodyText -> - ConversationMessageText( - text = bodyText, - style = MaterialTheme.typography.bodyLarge, - onExternalUriClick = onExternalUriClick, - ) - } -} - -@Composable -private fun ConversationMessageSender( - modifier: Modifier = Modifier, - color: Color, - senderDisplayName: String?, - showSender: Boolean, -) { - if (!showSender || senderDisplayName == null) { - return - } - - Text( - modifier = modifier, - text = senderDisplayName, - style = MaterialTheme.typography.labelMedium, - color = color, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) -} - -@Composable -private fun ConversationMessageMetadata( - message: ConversationMessageUiModel, - metadataText: String?, -) { - if (metadataText == null) { - return - } - - Text( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 6.dp), - text = metadataText, - style = MaterialTheme.typography.labelSmall, - color = messageMetadataColor(message = message), - textAlign = messageMetadataTextAlign(message = message), - ) -} - private fun messageContentHorizontalAlignment( message: ConversationMessageUiModel, ): Alignment.Horizontal { @@ -658,61 +297,6 @@ private fun messageContentHorizontalAlignment( } } -private fun messageMetadataTextAlign(message: ConversationMessageUiModel): TextAlign { - return when { - message.isIncoming -> TextAlign.Start - else -> TextAlign.End - } -} - -@Composable -private fun messageBubbleColor( - message: ConversationMessageUiModel, - isSelected: Boolean, -): Color { - return when { - isSelected -> MaterialTheme.colorScheme.primary - message.isIncoming -> MaterialTheme.colorScheme.surfaceContainerHigh - else -> MaterialTheme.colorScheme.primaryContainer - } -} - -@Composable -private fun messageBubbleContentColor( - message: ConversationMessageUiModel, - isSelected: Boolean, -): Color { - return when { - isSelected -> MaterialTheme.colorScheme.onPrimary - message.isIncoming -> MaterialTheme.colorScheme.onSurface - else -> MaterialTheme.colorScheme.onPrimaryContainer - } -} - -@Composable -private fun messageSenderColor( - message: ConversationMessageUiModel, - isSelected: Boolean, -): Color { - return when { - isSelected -> { - messageBubbleContentColor( - message = message, - isSelected = true, - ) - } - - message.isIncoming -> MaterialTheme.colorScheme.primary - - else -> { - messageBubbleContentColor( - message = message, - isSelected = false, - ) - } - } -} - private fun messageBubbleShape(message: ConversationMessageUiModel): RoundedCornerShape { val cornerRadius = MESSAGE_BUBBLE_CORNER_RADIUS_DP.dp @@ -825,19 +409,3 @@ private fun messageStatusTextResourceId(status: Status): Int? { else -> null } } - -@Composable -private fun messageMetadataColor( - message: ConversationMessageUiModel, -): Color { - return when (message.status) { - Status.Outgoing.AwaitingRetry, - Status.Outgoing.Failed, - Status.Outgoing.FailedEmergencyNumber, - Status.Incoming.DownloadFailed, - Status.Incoming.ExpiredOrNotAvailable, - -> MaterialTheme.colorScheme.error - - else -> MaterialTheme.colorScheme.onSurfaceVariant - } -} diff --git a/src/com/android/messaging/ui/conversation/v2/messages/ui/message/ConversationMessageBubble.kt b/src/com/android/messaging/ui/conversation/v2/messages/ui/message/ConversationMessageBubble.kt new file mode 100644 index 00000000..3ec735fc --- /dev/null +++ b/src/com/android/messaging/ui/conversation/v2/messages/ui/message/ConversationMessageBubble.kt @@ -0,0 +1,448 @@ +package com.android.messaging.ui.conversation.v2.messages.ui.message + +import androidx.compose.animation.animateColorAsState +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.RoundedCornerShape +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.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.android.messaging.ui.conversation.v2.messages.model.message.ConversationMessageContent +import com.android.messaging.ui.conversation.v2.messages.model.message.ConversationMessageUiModel +import com.android.messaging.ui.conversation.v2.messages.model.message.ConversationMessageUiModel.Status +import com.android.messaging.ui.conversation.v2.messages.ui.attachment.ConversationMessageAttachments +import com.android.messaging.ui.conversation.v2.messages.ui.text.ConversationMessageText + +private val MESSAGE_BUBBLE_MEDIA_SECTION_SPACING = 8.dp +private val MESSAGE_BUBBLE_MEDIA_TEXT_PADDING = 12.dp +private val MESSAGE_BUBBLE_TEXT_HORIZONTAL_PADDING = 16.dp +private val MESSAGE_BUBBLE_TEXT_VERTICAL_PADDING = 12.dp +private const val MESSAGE_SELECTION_MEDIA_OVERLAY_ALPHA = 0.2f + +@Composable +internal fun ConversationMessageBubble( + modifier: Modifier = Modifier, + message: ConversationMessageUiModel, + isSelected: Boolean, + isSelectionMode: Boolean, + layout: ConversationMessageLayout, + maxBubbleWidth: Dp, + onAttachmentClick: (contentType: String, contentUri: String) -> Unit, + onExternalUriClick: (String) -> Unit, + onMessageLongClick: () -> Unit, +) { + when (layout.bubbleLayoutMode) { + ConversationMessageBubbleLayoutMode.AttachmentOnlyWithoutSurface -> { + ConversationMessageAttachmentOnlyContainer( + modifier = Modifier + .widthIn(max = maxBubbleWidth) + .then(other = modifier), + bubbleShape = layout.bubbleShape, + message = message, + isSelected = isSelected, + ) { + ConversationMessageAttachmentBubbleContent( + modifier = Modifier + .fillMaxWidth(), + content = layout.content, + message = message, + isSelected = isSelected, + isSelectionMode = isSelectionMode, + senderDisplayName = message.senderDisplayName, + showSender = layout.showSender, + onAttachmentClick = onAttachmentClick, + onExternalUriClick = onExternalUriClick, + onMessageLongClick = onMessageLongClick, + ) + } + } + + ConversationMessageBubbleLayoutMode.AttachmentsInSurface -> { + ConversationMessageBubbleSurface( + modifier = Modifier + .widthIn(max = maxBubbleWidth) + .then(other = modifier), + isSelected = isSelected, + message = message, + layout = layout, + ) { + ConversationMessageAttachmentBubbleContent( + content = layout.content, + message = message, + isSelected = isSelected, + isSelectionMode = isSelectionMode, + senderDisplayName = message.senderDisplayName, + showSender = layout.showSender, + onAttachmentClick = onAttachmentClick, + onExternalUriClick = onExternalUriClick, + onMessageLongClick = onMessageLongClick, + ) + } + } + + ConversationMessageBubbleLayoutMode.TextInSurface -> { + ConversationMessageBubbleSurface( + modifier = Modifier + .widthIn(max = maxBubbleWidth) + .then(other = modifier), + isSelected = isSelected, + message = message, + layout = layout, + ) { + ConversationMessageTextBubbleContent( + content = layout.content, + message = message, + isSelected = isSelected, + isSelectionMode = isSelectionMode, + senderDisplayName = message.senderDisplayName, + showSender = layout.showSender, + onAttachmentClick = onAttachmentClick, + onExternalUriClick = onExternalUriClick, + onMessageLongClick = onMessageLongClick, + ) + } + } + } +} + +@Composable +internal fun ConversationMessageMetadata( + message: ConversationMessageUiModel, + metadataText: String?, +) { + if (metadataText == null) { + return + } + + Text( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 6.dp), + text = metadataText, + style = MaterialTheme.typography.labelSmall, + color = messageMetadataColor(message = message), + textAlign = messageMetadataTextAlign(message = message), + ) +} + +@Composable +private fun ConversationMessageBubbleSurface( + modifier: Modifier = Modifier, + isSelected: Boolean, + message: ConversationMessageUiModel, + layout: ConversationMessageLayout, + bubbleContent: @Composable () -> Unit, +) { + Surface( + color = messageBubbleColor( + message = message, + isSelected = isSelected, + ), + contentColor = messageBubbleContentColor( + message = message, + isSelected = isSelected, + ), + shape = layout.bubbleShape, + modifier = modifier, + ) { + bubbleContent() + } +} + +@Composable +private fun ConversationMessageAttachmentOnlyContainer( + modifier: Modifier = Modifier, + bubbleShape: RoundedCornerShape, + message: ConversationMessageUiModel, + isSelected: Boolean, + content: @Composable () -> Unit, +) { + val overlayColor by animateColorAsState( + targetValue = when { + isSelected -> { + messageBubbleColor( + message = message, + isSelected = true, + ).copy(alpha = MESSAGE_SELECTION_MEDIA_OVERLAY_ALPHA) + } + + else -> Color.Transparent + }, + label = "conversationMessageSelectionOverlayColor", + ) + + Box( + modifier = modifier.clip(shape = bubbleShape), + ) { + content() + + if (overlayColor != Color.Transparent) { + Box( + modifier = Modifier + .fillMaxSize() + .clip(shape = bubbleShape) + .background(color = overlayColor), + ) + } + } +} + +@Composable +private fun ConversationMessageTextBubbleContent( + content: ConversationMessageContent, + message: ConversationMessageUiModel, + isSelected: Boolean, + isSelectionMode: Boolean, + senderDisplayName: String?, + showSender: Boolean, + onAttachmentClick: (contentType: String, contentUri: String) -> Unit, + onExternalUriClick: (String) -> Unit, + onMessageLongClick: () -> Unit, +) { + Column( + modifier = Modifier.padding( + horizontal = MESSAGE_BUBBLE_TEXT_HORIZONTAL_PADDING, + vertical = MESSAGE_BUBBLE_TEXT_VERTICAL_PADDING, + ), + verticalArrangement = Arrangement.spacedBy(space = 8.dp), + ) { + ConversationMessageSender( + color = messageSenderColor( + message = message, + isSelected = isSelected, + ), + senderDisplayName = senderDisplayName, + showSender = showSender, + ) + + ConversationMessageBody( + content = content, + isIncoming = message.isIncoming, + isSelectionMode = isSelectionMode, + onAttachmentClick = onAttachmentClick, + onExternalUriClick = onExternalUriClick, + onMessageLongClick = onMessageLongClick, + ) + } +} + +@Composable +private fun ConversationMessageAttachmentBubbleContent( + modifier: Modifier = Modifier, + content: ConversationMessageContent, + message: ConversationMessageUiModel, + isSelected: Boolean, + isSelectionMode: Boolean, + senderDisplayName: String?, + showSender: Boolean, + onAttachmentClick: (contentType: String, contentUri: String) -> Unit, + onExternalUriClick: (String) -> Unit, + onMessageLongClick: () -> Unit, +) { + val hasHeader = showSender || !content.subjectText.isNullOrBlank() + val hasBodyText = !content.bodyText.isNullOrBlank() + + Column( + modifier = modifier.fillMaxWidth(), + ) { + ConversationMessageSender( + modifier = Modifier.padding( + start = MESSAGE_BUBBLE_MEDIA_TEXT_PADDING, + top = MESSAGE_BUBBLE_MEDIA_TEXT_PADDING, + end = MESSAGE_BUBBLE_MEDIA_TEXT_PADDING, + bottom = when { + content.subjectText.isNullOrBlank() -> 6.dp + else -> MESSAGE_BUBBLE_MEDIA_SECTION_SPACING + }, + ), + color = messageSenderColor( + message = message, + isSelected = isSelected, + ), + senderDisplayName = senderDisplayName, + showSender = showSender, + ) + + content.subjectText?.let { subjectText -> + Text( + modifier = Modifier.padding( + start = MESSAGE_BUBBLE_MEDIA_TEXT_PADDING, + end = MESSAGE_BUBBLE_MEDIA_TEXT_PADDING, + bottom = MESSAGE_BUBBLE_MEDIA_SECTION_SPACING, + ), + text = subjectText, + style = MaterialTheme.typography.titleSmall, + ) + } + + ConversationMessageAttachments( + attachmentSections = content.attachmentSections, + hasTextAboveVisualAttachments = hasHeader, + hasTextBelowVisualAttachments = hasBodyText, + isIncoming = message.isIncoming, + isSelectionMode = isSelectionMode, + useStandaloneAudioAttachmentBg = false, + onAttachmentClick = onAttachmentClick, + onExternalUriClick = onExternalUriClick, + onMessageLongClick = onMessageLongClick, + ) + + content.bodyText?.let { bodyText -> + ConversationMessageText( + modifier = Modifier.padding( + start = MESSAGE_BUBBLE_MEDIA_TEXT_PADDING, + top = MESSAGE_BUBBLE_MEDIA_SECTION_SPACING, + end = MESSAGE_BUBBLE_MEDIA_TEXT_PADDING, + bottom = MESSAGE_BUBBLE_MEDIA_TEXT_PADDING, + ), + text = bodyText, + style = MaterialTheme.typography.bodyLarge, + onExternalUriClick = onExternalUriClick, + ) + } + } +} + +@Composable +private fun ConversationMessageBody( + content: ConversationMessageContent, + isIncoming: Boolean, + isSelectionMode: Boolean, + onAttachmentClick: (contentType: String, contentUri: String) -> Unit, + onExternalUriClick: (String) -> Unit, + onMessageLongClick: () -> Unit, +) { + content.subjectText?.let { subjectText -> + Text( + text = subjectText, + style = MaterialTheme.typography.titleSmall, + ) + } + + ConversationMessageAttachments( + attachmentSections = content.attachmentSections, + hasTextAboveVisualAttachments = false, + hasTextBelowVisualAttachments = false, + isIncoming = isIncoming, + isSelectionMode = isSelectionMode, + useStandaloneAudioAttachmentBg = true, + onAttachmentClick = onAttachmentClick, + onExternalUriClick = onExternalUriClick, + onMessageLongClick = onMessageLongClick, + ) + + content.bodyText?.let { bodyText -> + ConversationMessageText( + text = bodyText, + style = MaterialTheme.typography.bodyLarge, + onExternalUriClick = onExternalUriClick, + ) + } +} + +@Composable +private fun ConversationMessageSender( + modifier: Modifier = Modifier, + color: Color, + senderDisplayName: String?, + showSender: Boolean, +) { + if (!showSender || senderDisplayName == null) { + return + } + + Text( + modifier = modifier, + text = senderDisplayName, + style = MaterialTheme.typography.labelMedium, + color = color, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) +} + +private fun messageMetadataTextAlign(message: ConversationMessageUiModel): TextAlign { + return when { + message.isIncoming -> TextAlign.Start + else -> TextAlign.End + } +} + +@Composable +private fun messageBubbleColor( + message: ConversationMessageUiModel, + isSelected: Boolean, +): Color { + return when { + isSelected -> MaterialTheme.colorScheme.primary + message.isIncoming -> MaterialTheme.colorScheme.surfaceContainerHigh + else -> MaterialTheme.colorScheme.primaryContainer + } +} + +@Composable +private fun messageBubbleContentColor( + message: ConversationMessageUiModel, + isSelected: Boolean, +): Color { + return when { + isSelected -> MaterialTheme.colorScheme.onPrimary + message.isIncoming -> MaterialTheme.colorScheme.onSurface + else -> MaterialTheme.colorScheme.onPrimaryContainer + } +} + +@Composable +private fun messageSenderColor( + message: ConversationMessageUiModel, + isSelected: Boolean, +): Color { + return when { + isSelected -> { + messageBubbleContentColor( + message = message, + isSelected = true, + ) + } + + message.isIncoming -> MaterialTheme.colorScheme.primary + + else -> { + messageBubbleContentColor( + message = message, + isSelected = false, + ) + } + } +} + +@Composable +private fun messageMetadataColor( + message: ConversationMessageUiModel, +): Color { + return when (message.status) { + Status.Outgoing.AwaitingRetry, + Status.Outgoing.Failed, + Status.Outgoing.FailedEmergencyNumber, + Status.Incoming.DownloadFailed, + Status.Incoming.ExpiredOrNotAvailable, + -> MaterialTheme.colorScheme.error + + else -> MaterialTheme.colorScheme.onSurfaceVariant + } +} diff --git a/src/com/android/messaging/ui/conversation/v2/metadata/ui/ConversationTopAppBar.kt b/src/com/android/messaging/ui/conversation/v2/metadata/ui/ConversationTopAppBar.kt index 1375736d..dfa8391b 100644 --- a/src/com/android/messaging/ui/conversation/v2/metadata/ui/ConversationTopAppBar.kt +++ b/src/com/android/messaging/ui/conversation/v2/metadata/ui/ConversationTopAppBar.kt @@ -111,15 +111,26 @@ internal fun ConversationTopAppBar( ) }, navigationIcon = { - ConversationTopAppBarNavigationIcon( - onNavigateBack = onNavigateBack, - ) + IconButton( + onClick = onNavigateBack, + ) { + Icon( + imageVector = Icons.AutoMirrored.Rounded.ArrowBack, + contentDescription = stringResource(id = R.string.back), + ) + } }, actions = { if (isCallVisible) { - ConversationTopAppBarCallAction( - onCallClick = onCallClick, - ) + IconButton( + modifier = Modifier.testTag(CONVERSATION_CALL_BUTTON_TEST_TAG), + onClick = onCallClick, + ) { + Icon( + imageVector = Icons.Rounded.Call, + contentDescription = stringResource(id = R.string.action_call), + ) + } } val isSimSelectorVisible = simSelector.isAvailable @@ -251,35 +262,6 @@ private fun ConversationTopAppBarText( } } -@Composable -private fun ConversationTopAppBarNavigationIcon( - onNavigateBack: () -> Unit, -) { - IconButton( - onClick = onNavigateBack, - ) { - Icon( - imageVector = Icons.AutoMirrored.Rounded.ArrowBack, - contentDescription = stringResource(id = R.string.back), - ) - } -} - -@Composable -private fun ConversationTopAppBarCallAction( - onCallClick: () -> Unit, -) { - IconButton( - modifier = Modifier.testTag(CONVERSATION_CALL_BUTTON_TEST_TAG), - onClick = onCallClick, - ) { - Icon( - imageVector = Icons.Rounded.Call, - contentDescription = stringResource(id = R.string.action_call), - ) - } -} - @Composable private fun ConversationTopAppBarOverflowMenu( isAddPeopleVisible: Boolean, @@ -493,18 +475,6 @@ private fun conversationTitle( } } -private fun conversationIsGroup( - metadata: ConversationMetadataUiState, -): Boolean { - return when (metadata) { - ConversationMetadataUiState.Loading -> false - ConversationMetadataUiState.Unavailable -> false - is ConversationMetadataUiState.Present -> { - metadata.avatar is ConversationMetadataUiState.Avatar.Group - } - } -} - @Composable private fun conversationSubtitle( metadata: ConversationMetadataUiState, diff --git a/src/com/android/messaging/ui/conversation/v2/recipientpicker/RecipientSelectionContactAvatar.kt b/src/com/android/messaging/ui/conversation/v2/recipientpicker/RecipientSelectionContactAvatar.kt new file mode 100644 index 00000000..adbba6d1 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/v2/recipientpicker/RecipientSelectionContactAvatar.kt @@ -0,0 +1,209 @@ +package com.android.messaging.ui.conversation.v2.recipientpicker + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.animation.core.updateTransition +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Check +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import com.android.messaging.R +import com.android.messaging.ui.conversation.v2.recipientpicker.model.RecipientPickerListItem + +@Composable +internal fun RecipientSelectionContactAvatar( + item: RecipientPickerListItem, + isSelected: Boolean, +) { + val avatarScale by rememberRecipientSelectionContactAvatarScale( + isSelected = isSelected, + ) + + AnimatedContent( + targetState = isSelected, + transitionSpec = { + ( + fadeIn( + animationSpec = tween(durationMillis = 200), + ) + scaleIn( + animationSpec = spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = Spring.StiffnessMediumLow, + ), + initialScale = 0.8f, + ) + ).togetherWith( + fadeOut( + animationSpec = tween(durationMillis = 150), + ) + scaleOut( + animationSpec = spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = Spring.StiffnessMediumLow, + ), + targetScale = 0.8f, + ), + ) + }, + label = "recipientSelectionContactAvatar", + ) { isSelectedState -> + Box( + modifier = Modifier.graphicsLayer { + scaleX = avatarScale + scaleY = avatarScale + }, + ) { + when { + isSelectedState -> { + RecipientSelectionSelectedAvatar() + } + + recipientSelectionPhotoUri(item = item) == null -> { + RecipientSelectionTextAvatar(item = item) + } + + else -> { + AsyncImage( + modifier = Modifier + .size(size = 40.dp) + .clip(shape = CircleShape), + model = recipientSelectionPhotoUri(item = item), + contentDescription = recipientSelectionItemDisplayName(item = item), + ) + } + } + } + } +} + +@Composable +private fun RecipientSelectionSelectedAvatar( + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier + .size(size = 40.dp) + .background( + color = MaterialTheme.colorScheme.primary, + shape = CircleShape, + ), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Rounded.Check, + contentDescription = null, + tint = MaterialTheme.colorScheme.onPrimary, + ) + } +} + +@Composable +private fun RecipientSelectionTextAvatar( + item: RecipientPickerListItem, + modifier: Modifier = Modifier, +) { + val displayName = recipientSelectionItemDisplayName(item = item) + val label = remember(displayName, item.destination) { + recipientSelectionAvatarLabel( + displayName = displayName, + destination = item.destination, + ) + } + + Box( + modifier = modifier + .size(size = 40.dp) + .background( + color = MaterialTheme.colorScheme.secondaryContainer, + shape = CircleShape, + ), + contentAlignment = Alignment.Center, + ) { + Text( + text = label, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSecondaryContainer, + ) + } +} + +@Composable +internal fun recipientSelectionItemDisplayName( + item: RecipientPickerListItem, +): String { + return when (item) { + is RecipientPickerListItem.Contact -> item.recipient.displayName + is RecipientPickerListItem.SyntheticPhone -> { + stringResource( + id = R.string.contact_list_send_to_text, + item.rawQuery, + ) + } + } +} + +private fun recipientSelectionPhotoUri(item: RecipientPickerListItem): String? { + return when (item) { + is RecipientPickerListItem.Contact -> item.recipient.photoUri + is RecipientPickerListItem.SyntheticPhone -> null + } +} + +private fun recipientSelectionAvatarLabel( + displayName: String, + destination: String, +): String { + val labelSource = displayName.ifBlank { destination } + val firstCharacter = labelSource.firstOrNull() ?: '?' + + return firstCharacter.uppercaseChar().toString() +} + +@Composable +private fun rememberRecipientSelectionContactAvatarScale( + isSelected: Boolean, +): State { + val selectionTransition = updateTransition( + targetState = isSelected, + label = "recipientSelectionContactAvatarScale", + ) + + return selectionTransition.animateFloat( + transitionSpec = { + spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = Spring.StiffnessMediumLow, + ) + }, + label = "recipientSelectionContactAvatarScaleValue", + targetValueByState = { isAvatarSelected -> + when { + isAvatarSelected -> 1f + else -> 0.9f + } + }, + ) +} diff --git a/src/com/android/messaging/ui/conversation/v2/recipientpicker/RecipientSelectionContactRow.kt b/src/com/android/messaging/ui/conversation/v2/recipientpicker/RecipientSelectionContactRow.kt new file mode 100644 index 00000000..95e84038 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/v2/recipientpicker/RecipientSelectionContactRow.kt @@ -0,0 +1,274 @@ +package com.android.messaging.ui.conversation.v2.recipientpicker + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateColor +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.Transition +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.animation.core.updateTransition +import androidx.compose.animation.fadeIn +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.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +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.platform.testTag +import androidx.compose.ui.semantics.selected +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.android.messaging.ui.conversation.v2.recipientpicker.model.RecipientPickerListItem + +private val contactCornerRadius = 18.dp +private val contactMiddleCornerRadius = 2.dp +private val topContactShape = RoundedCornerShape( + topStart = contactCornerRadius, + topEnd = contactCornerRadius, + bottomStart = contactMiddleCornerRadius, + bottomEnd = contactMiddleCornerRadius, +) +private val bottomContactShape = RoundedCornerShape( + topStart = contactMiddleCornerRadius, + topEnd = contactMiddleCornerRadius, + bottomStart = contactCornerRadius, + bottomEnd = contactCornerRadius, +) +private val middleContactShape = RoundedCornerShape(size = contactMiddleCornerRadius) +private val singleContactShape = RoundedCornerShape(size = contactCornerRadius) + +@Composable +internal fun RecipientSelectionContactRow( + item: RecipientPickerListItem, + enabled: Boolean, + isSelected: Boolean, + onClick: () -> Unit, + shape: RoundedCornerShape, + rowTestTag: String, + modifier: Modifier = Modifier, + onLongClick: (() -> Unit)? = null, + showTrailingIndicator: Boolean = false, + trailingIndicatorTestTag: String? = null, +) { + val hapticFeedback = LocalHapticFeedback.current + val selectionTransition = updateTransition( + targetState = isSelected, + label = "recipientSelectionContactSelection", + ) + + val containerColor by selectionTransition.animateContainerColor() + val primaryTextColor by selectionTransition.animatePrimaryTextColor() + val secondaryTextColor by selectionTransition.animateSecondaryTextColor() + + Row( + modifier = Modifier + .then(other = modifier) + .fillMaxWidth() + .testTag(rowTestTag) + .semantics { + selected = isSelected + } + .background( + color = containerColor, + shape = shape, + ) + .combinedClickable( + enabled = enabled, + onClick = { + hapticFeedback.performHapticFeedback(HapticFeedbackType.ContextClick) + onClick() + }, + onLongClick = onLongClick?.let { callback -> + { + hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) + callback() + } + }, + ) + .padding(horizontal = 16.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + RecipientSelectionContactAvatar( + item = item, + isSelected = isSelected, + ) + + RecipientSelectionContactText( + item = item, + primaryTextColor = primaryTextColor, + secondaryTextColor = secondaryTextColor, + ) + + RecipientSelectionTrailingIndicator( + visible = showTrailingIndicator, + testTag = trailingIndicatorTestTag, + ) + } +} + +@Composable +private fun RowScope.RecipientSelectionContactText( + item: RecipientPickerListItem, + primaryTextColor: Color, + secondaryTextColor: Color, +) { + Column( + modifier = Modifier + .padding(start = 14.dp) + .weight(weight = 1f), + verticalArrangement = Arrangement.spacedBy(space = 2.dp), + ) { + Text( + text = recipientSelectionItemDisplayName(item = item), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.bodyLarge, + color = primaryTextColor, + ) + + item.secondaryText?.let { secondaryText -> + Text( + text = secondaryText, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.bodyMedium, + color = secondaryTextColor, + ) + } + } +} + +@Composable +private fun RecipientSelectionTrailingIndicator( + visible: Boolean, + testTag: String?, +) { + AnimatedVisibility( + visible = visible, + enter = fadeIn( + animationSpec = tween(durationMillis = 200), + ) + scaleIn( + animationSpec = spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = Spring.StiffnessMediumLow, + ), + initialScale = 0.8f, + ), + exit = fadeOut( + animationSpec = tween(durationMillis = 150), + ) + scaleOut( + animationSpec = spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = Spring.StiffnessMediumLow, + ), + targetScale = 0.8f, + ), + ) { + CircularProgressIndicator( + modifier = when { + testTag != null -> { + Modifier + .size(size = 20.dp) + .testTag(testTag) + } + + else -> { + Modifier.size(size = 20.dp) + } + }, + strokeWidth = 2.dp, + ) + } +} + +internal fun recipientSelectionContactRowShape( + index: Int, + totalCount: Int, +): RoundedCornerShape { + return when { + totalCount <= 1 -> singleContactShape + index == 0 -> topContactShape + index == totalCount - 1 -> bottomContactShape + else -> middleContactShape + } +} + +@Composable +private fun Transition.animateContainerColor(): State { + return animateColor( + transitionSpec = { + tween( + durationMillis = 200, + easing = FastOutSlowInEasing, + ) + }, + label = "recipientSelectionContactContainerColor", + targetValueByState = { isContactSelected -> + when { + isContactSelected -> MaterialTheme.colorScheme.secondaryContainer + else -> MaterialTheme.colorScheme.background + } + }, + ) +} + +@Composable +private fun Transition.animatePrimaryTextColor(): State { + return animateColor( + transitionSpec = { + tween( + durationMillis = 200, + easing = FastOutSlowInEasing, + ) + }, + label = "recipientSelectionContactPrimaryTextColor", + targetValueByState = { isContactSelected -> + when { + isContactSelected -> MaterialTheme.colorScheme.onSecondaryContainer + else -> MaterialTheme.colorScheme.onSurface + } + }, + ) +} + +@Composable +private fun Transition.animateSecondaryTextColor(): State { + return animateColor( + transitionSpec = { + tween( + durationMillis = 200, + easing = FastOutSlowInEasing, + ) + }, + label = "recipientSelectionContactSecondaryTextColor", + targetValueByState = { isContactSelected -> + when { + isContactSelected -> { + MaterialTheme.colorScheme.onSecondaryContainer.copy(alpha = 0.7f) + } + + else -> MaterialTheme.colorScheme.onSurfaceVariant + } + }, + ) +} diff --git a/src/com/android/messaging/ui/conversation/v2/recipientpicker/RecipientSelectionContactsContent.kt b/src/com/android/messaging/ui/conversation/v2/recipientpicker/RecipientSelectionContactsContent.kt new file mode 100644 index 00000000..c8a04636 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/v2/recipientpicker/RecipientSelectionContactsContent.kt @@ -0,0 +1,342 @@ +package com.android.messaging.ui.conversation.v2.recipientpicker + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.android.messaging.R +import com.android.messaging.ui.conversation.v2.recipientpicker.model.RecipientPickerListItem +import com.android.messaging.ui.conversation.v2.recipientpicker.model.RecipientPickerUiState + +private const val CONTACTS_LOAD_MORE_THRESHOLD = 10 +private const val RECIPIENT_CONTACT_CONTENT_TYPE = "recipient_contact" + +@Composable +internal fun RecipientSelectionContactsContent( + uiState: RecipientSelectionContentUiState, + rowDecorators: RecipientSelectionRowDecorators, + onLoadMore: () -> Unit, + onPrimaryActionClick: () -> Unit, + onRecipientClick: (RecipientPickerListItem) -> Unit, + onRecipientLongClick: ((RecipientPickerListItem) -> Unit)?, + modifier: Modifier = Modifier, + topListContent: (@Composable () -> Unit)? = null, +) { + val primaryAction = uiState.primaryAction + + Box(modifier = modifier) { + RecipientSelectionContactsList( + uiState = uiState, + rowDecorators = rowDecorators, + onLoadMore = onLoadMore, + onRecipientClick = onRecipientClick, + onRecipientLongClick = onRecipientLongClick, + topListContent = topListContent, + ) + + AnimatedVisibility( + modifier = Modifier.align(alignment = Alignment.BottomEnd), + visible = primaryAction != null, + enter = recipientSelectionPrimaryActionEnterTransition(), + exit = recipientSelectionPrimaryActionExitTransition(), + ) { + RecipientSelectionPrimaryActionButton( + modifier = Modifier + .navigationBarsPadding() + .padding(end = 8.dp, bottom = 8.dp), + enabled = primaryAction?.isEnabled ?: false, + isLoading = primaryAction?.isLoading ?: false, + text = primaryAction?.text.orEmpty(), + testTag = primaryAction?.testTag, + onClick = onPrimaryActionClick, + ) + } + } +} + +@Composable +private fun RecipientSelectionContactsList( + uiState: RecipientSelectionContentUiState, + rowDecorators: RecipientSelectionRowDecorators, + onLoadMore: () -> Unit, + onRecipientClick: (RecipientPickerListItem) -> Unit, + onRecipientLongClick: ((RecipientPickerListItem) -> Unit)?, + topListContent: (@Composable () -> Unit)?, +) { + val pickerUiState = uiState.picker + val listState = rememberLazyListState() + val animatedListBottomPadding by animateDpAsState( + targetValue = when { + uiState.primaryAction != null -> 100.dp + else -> 16.dp + }, + animationSpec = spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = Spring.StiffnessMediumLow, + ), + label = "recipientSelectionListBottomPadding", + ) + + RecipientSelectionLoadMoreEffect( + listState = listState, + pickerUiState = pickerUiState, + onLoadMore = onLoadMore, + ) + + LazyColumn( + modifier = Modifier.fillMaxSize(), + state = listState, + contentPadding = PaddingValues(bottom = animatedListBottomPadding), + ) { + topListContent?.let { + item { + topListContent() + } + } + + recipientSelectionContactItems( + uiState = uiState, + rowDecorators = rowDecorators, + onRecipientClick = onRecipientClick, + onRecipientLongClick = onRecipientLongClick, + ) + } +} + +private fun LazyListScope.recipientSelectionContactItems( + uiState: RecipientSelectionContentUiState, + rowDecorators: RecipientSelectionRowDecorators, + onRecipientClick: (RecipientPickerListItem) -> Unit, + onRecipientLongClick: ((RecipientPickerListItem) -> Unit)?, +) { + val pickerUiState = uiState.picker + + when { + pickerUiState.isLoading -> { + item { + RecipientSelectionLoadingState() + } + } + + pickerUiState.items.isEmpty() -> { + item { + RecipientSelectionEmptyState() + } + } + + else -> { + itemsIndexed( + items = pickerUiState.items, + key = { _, item -> item.id }, + contentType = { _, _ -> RECIPIENT_CONTACT_CONTENT_TYPE }, + ) { index, item -> + RecipientSelectionContactItem( + item = item, + index = index, + uiState = uiState, + rowDecorators = rowDecorators, + onRecipientClick = onRecipientClick, + onRecipientLongClick = onRecipientLongClick, + ) + } + } + } + + if (pickerUiState.isLoadingMore) { + item { + RecipientSelectionLoadingMoreState() + } + } +} + +@Composable +private fun RecipientSelectionContactItem( + item: RecipientPickerListItem, + index: Int, + uiState: RecipientSelectionContentUiState, + rowDecorators: RecipientSelectionRowDecorators, + onRecipientClick: (RecipientPickerListItem) -> Unit, + onRecipientLongClick: ((RecipientPickerListItem) -> Unit)?, +) { + val lastContactIndex = uiState.picker.items.lastIndex + val bottomPadding = when { + index == lastContactIndex -> 0.dp + else -> 2.dp + } + + RecipientSelectionContactRow( + modifier = Modifier.padding(bottom = bottomPadding), + item = item, + enabled = uiState.primaryAction?.isLoading != true, + isSelected = uiState.selectedRecipientDestinations.contains(item.destination), + onClick = { + onRecipientClick(item) + }, + onLongClick = onRecipientLongClick?.let { callback -> + { + callback(item) + } + }, + rowTestTag = rowDecorators.recipientRowTestTag(item), + shape = recipientSelectionContactRowShape( + index = index, + totalCount = uiState.picker.items.size, + ), + showTrailingIndicator = rowDecorators.showRecipientTrailingIndicator(item), + trailingIndicatorTestTag = rowDecorators.trailingIndicatorTestTag, + ) +} + +@Composable +private fun RecipientSelectionLoadMoreEffect( + listState: LazyListState, + pickerUiState: RecipientPickerUiState, + onLoadMore: () -> Unit, +) { + LaunchedEffect( + listState, + pickerUiState.canLoadMore, + pickerUiState.isLoading, + pickerUiState.isLoadingMore, + pickerUiState.items.size, + ) { + snapshotFlow { + val lastVisibleIndex = listState + .layoutInfo + .visibleItemsInfo + .lastOrNull() + ?.index + ?: -1 + + lastVisibleIndex >= pickerUiState.items.lastIndex - CONTACTS_LOAD_MORE_THRESHOLD + }.collect { isNearEnd -> + if ( + shouldRequestRecipientSelectionLoadMore( + isNearEnd = isNearEnd, + pickerUiState = pickerUiState, + ) + ) { + onLoadMore() + } + } + } +} + +private fun shouldRequestRecipientSelectionLoadMore( + isNearEnd: Boolean, + pickerUiState: RecipientPickerUiState, +): Boolean { + return isNearEnd && + pickerUiState.canLoadMore && + !pickerUiState.isLoading && + !pickerUiState.isLoadingMore +} + +private fun recipientSelectionPrimaryActionEnterTransition(): EnterTransition { + return fadeIn( + animationSpec = tween(durationMillis = 200), + ) + slideInVertically( + animationSpec = spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = Spring.StiffnessMediumLow, + ), + initialOffsetY = { fullHeight -> + fullHeight / 2 + }, + ) + scaleIn( + animationSpec = spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = Spring.StiffnessMediumLow, + ), + initialScale = 0.9f, + ) +} + +private fun recipientSelectionPrimaryActionExitTransition(): ExitTransition { + return fadeOut( + animationSpec = tween(durationMillis = 150), + ) + slideOutVertically( + animationSpec = spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = Spring.StiffnessMediumLow, + ), + targetOffsetY = { fullHeight -> + fullHeight / 2 + }, + ) + scaleOut( + animationSpec = spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = Spring.StiffnessMediumLow, + ), + targetScale = 0.9f, + ) +} + +@Composable +private fun RecipientSelectionLoadingState() { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 32.dp), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } +} + +@Composable +private fun RecipientSelectionLoadingMoreState() { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 16.dp), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator( + modifier = Modifier.size(size = 20.dp), + strokeWidth = 2.dp, + ) + } +} + +@Composable +private fun RecipientSelectionEmptyState() { + Text( + modifier = Modifier.padding(vertical = 24.dp, horizontal = 4.dp), + text = stringResource(id = R.string.contact_list_empty_text), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) +} diff --git a/src/com/android/messaging/ui/conversation/v2/recipientpicker/RecipientSelectionContent.kt b/src/com/android/messaging/ui/conversation/v2/recipientpicker/RecipientSelectionContent.kt index f6a7fb56..13f575a8 100644 --- a/src/com/android/messaging/ui/conversation/v2/recipientpicker/RecipientSelectionContent.kt +++ b/src/com/android/messaging/ui/conversation/v2/recipientpicker/RecipientSelectionContent.kt @@ -4,104 +4,26 @@ package com.android.messaging.ui.conversation.v2.recipientpicker -import androidx.compose.animation.AnimatedContent -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.ContentTransform -import androidx.compose.animation.EnterTransition -import androidx.compose.animation.ExitTransition -import androidx.compose.animation.animateColor -import androidx.compose.animation.animateContentSize -import androidx.compose.animation.core.FastOutSlowInEasing -import androidx.compose.animation.core.FiniteAnimationSpec -import androidx.compose.animation.core.LinearOutSlowInEasing -import androidx.compose.animation.core.Spring -import androidx.compose.animation.core.Transition -import androidx.compose.animation.core.animateDpAsState -import androidx.compose.animation.core.animateFloat -import androidx.compose.animation.core.spring -import androidx.compose.animation.core.tween -import androidx.compose.animation.core.updateTransition -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.scaleIn -import androidx.compose.animation.scaleOut -import androidx.compose.animation.slideInVertically -import androidx.compose.animation.slideOutVertically -import androidx.compose.animation.togetherWith -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 -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.rounded.ArrowForward -import androidx.compose.material.icons.rounded.Check -import androidx.compose.material3.Button -import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextField import androidx.compose.material3.TextFieldDefaults import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.State -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import androidx.compose.runtime.snapshotFlow -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.hapticfeedback.HapticFeedbackType -import androidx.compose.ui.platform.LocalHapticFeedback -import androidx.compose.ui.platform.testTag -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.semantics.selected -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import coil3.compose.AsyncImage -import com.android.messaging.R import com.android.messaging.ui.conversation.v2.recipientpicker.model.RecipientPickerListItem -private val contactCornerRadius = 18.dp -private val contactMiddleCornerRadius = 2.dp private val searchFieldShape = RoundedCornerShape(size = 22.dp) -private val topContactShape = RoundedCornerShape( - topStart = contactCornerRadius, - topEnd = contactCornerRadius, - bottomStart = contactMiddleCornerRadius, - bottomEnd = contactMiddleCornerRadius, -) -private val bottomContactShape = RoundedCornerShape( - topStart = contactMiddleCornerRadius, - topEnd = contactMiddleCornerRadius, - bottomStart = contactCornerRadius, - bottomEnd = contactCornerRadius, -) -private val middleContactShape = RoundedCornerShape(size = contactMiddleCornerRadius) -private val singleContactShape = RoundedCornerShape(size = contactCornerRadius) - -private const val CONTACTS_LOAD_MORE_THRESHOLD = 10 -private const val RECIPIENT_CONTACT_CONTENT_TYPE = "recipient_contact" @Composable internal fun RecipientSelectionContent( @@ -195,669 +117,3 @@ private fun RecipientSelectionQueryField( }, ) } - -@Composable -private fun RecipientSelectionContactsContent( - uiState: RecipientSelectionContentUiState, - rowDecorators: RecipientSelectionRowDecorators, - onLoadMore: () -> Unit, - onPrimaryActionClick: () -> Unit, - onRecipientClick: (RecipientPickerListItem) -> Unit, - onRecipientLongClick: ((RecipientPickerListItem) -> Unit)?, - modifier: Modifier = Modifier, - topListContent: (@Composable () -> Unit)? = null, -) { - val pickerUiState = uiState.picker - val primaryAction = uiState.primaryAction - val lastContactIndex = pickerUiState.items.lastIndex - val listState = rememberLazyListState() - - val animatedListBottomPadding by animateDpAsState( - targetValue = when { - primaryAction != null -> 100.dp - else -> 16.dp - }, - animationSpec = recipientSelectionSpatialAnimationSpec(), - label = "recipientSelectionListBottomPadding", - ) - - LaunchedEffect( - listState, - pickerUiState.canLoadMore, - pickerUiState.isLoading, - pickerUiState.isLoadingMore, - pickerUiState.items.size, - ) { - snapshotFlow { - val lastVisibleIndex = listState - .layoutInfo - .visibleItemsInfo - .lastOrNull() - ?.index - ?: -1 - - lastVisibleIndex >= lastContactIndex - CONTACTS_LOAD_MORE_THRESHOLD - }.collect { shouldLoadMore -> - if ( - shouldLoadMore && - pickerUiState.canLoadMore && - !pickerUiState.isLoading && - !pickerUiState.isLoadingMore - ) { - onLoadMore() - } - } - } - - Box(modifier = modifier) { - LazyColumn( - modifier = Modifier.fillMaxSize(), - state = listState, - contentPadding = PaddingValues(bottom = animatedListBottomPadding), - ) { - topListContent?.let { - item { - topListContent() - } - } - - when { - pickerUiState.isLoading -> { - item { - RecipientSelectionLoadingState() - } - } - - pickerUiState.items.isEmpty() -> { - item { - RecipientSelectionEmptyState() - } - } - - else -> { - itemsIndexed( - items = pickerUiState.items, - key = { _, item -> item.id }, - contentType = { _, _ -> RECIPIENT_CONTACT_CONTENT_TYPE }, - ) { index, item -> - val bottomPadding = when { - index == lastContactIndex -> 0.dp - else -> 2.dp - } - - RecipientSelectionContactRow( - modifier = Modifier.padding(bottom = bottomPadding), - item = item, - enabled = primaryAction?.isLoading != true, - isSelected = uiState.selectedRecipientDestinations.contains( - item.destination, - ), - onClick = { - onRecipientClick(item) - }, - onLongClick = onRecipientLongClick?.let { callback -> - { - callback(item) - } - }, - rowTestTag = rowDecorators.recipientRowTestTag(item), - shape = recipientSelectionContactRowShape( - index = index, - totalCount = pickerUiState.items.size, - ), - showTrailingIndicator = rowDecorators - .showRecipientTrailingIndicator( - item, - ), - trailingIndicatorTestTag = rowDecorators.trailingIndicatorTestTag, - ) - } - } - } - - if (pickerUiState.isLoadingMore) { - item { - RecipientSelectionLoadingMoreState() - } - } - } - - AnimatedVisibility( - modifier = Modifier - .align(alignment = Alignment.BottomEnd), - visible = primaryAction != null, - enter = recipientSelectionPrimaryActionEnterTransition(), - exit = recipientSelectionPrimaryActionExitTransition(), - ) { - RecipientSelectionPrimaryActionButton( - modifier = Modifier - .navigationBarsPadding() - .padding(end = 8.dp, bottom = 8.dp), - enabled = primaryAction?.isEnabled ?: false, - isLoading = primaryAction?.isLoading ?: false, - text = primaryAction?.text.orEmpty(), - testTag = primaryAction?.testTag, - onClick = onPrimaryActionClick, - ) - } - } -} - -@Composable -private fun RecipientSelectionLoadingState() { - Box( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 32.dp), - contentAlignment = Alignment.Center, - ) { - CircularProgressIndicator() - } -} - -@Composable -private fun RecipientSelectionLoadingMoreState() { - Box( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 16.dp), - contentAlignment = Alignment.Center, - ) { - CircularProgressIndicator( - modifier = Modifier - .size(size = 20.dp), - strokeWidth = 2.dp, - ) - } -} - -@Composable -private fun RecipientSelectionEmptyState() { - Text( - modifier = Modifier - .padding(vertical = 24.dp, horizontal = 4.dp), - text = stringResource(id = R.string.contact_list_empty_text), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) -} - -@Composable -private fun RecipientSelectionPrimaryActionButton( - enabled: Boolean, - isLoading: Boolean, - text: String, - onClick: () -> Unit, - modifier: Modifier = Modifier, - testTag: String? = null, -) { - val taggedModifier = when { - testTag != null -> modifier.testTag(testTag) - else -> modifier - } - - Button( - modifier = taggedModifier - .animateContentSize( - animationSpec = recipientSelectionSpatialAnimationSpec(), - ), - onClick = onClick, - enabled = enabled, - shape = RoundedCornerShape(size = 18.dp), - ) { - AnimatedContent( - targetState = isLoading, - transitionSpec = { - recipientSelectionPrimaryActionContentTransform() - }, - label = "recipientSelectionPrimaryActionButtonContent", - ) { isButtonLoading -> - when { - isButtonLoading -> { - CircularProgressIndicator( - modifier = Modifier.size(size = 18.dp), - color = MaterialTheme.colorScheme.onPrimary, - strokeWidth = 2.dp, - ) - } - - else -> { - Row( - verticalAlignment = Alignment.CenterVertically, - ) { - Text(text = text) - - Spacer(modifier = Modifier.size(size = 8.dp)) - - Icon( - imageVector = Icons.AutoMirrored.Rounded.ArrowForward, - contentDescription = null, - ) - } - } - } - } - } -} - -@Composable -private fun RecipientSelectionContactRow( - item: RecipientPickerListItem, - enabled: Boolean, - isSelected: Boolean, - onClick: () -> Unit, - shape: RoundedCornerShape, - rowTestTag: String, - modifier: Modifier = Modifier, - onLongClick: (() -> Unit)? = null, - showTrailingIndicator: Boolean = false, - trailingIndicatorTestTag: String? = null, -) { - val hapticFeedback = LocalHapticFeedback.current - val selectionTransition = updateTransition( - targetState = isSelected, - label = "recipientSelectionContactSelection", - ) - - val containerColor by selectionTransition.animateContainerColor() - val primaryTextColor by selectionTransition.animatePrimaryTextColor() - val secondaryTextColor by selectionTransition.animateSecondaryTextColor() - - Row( - modifier = Modifier - .then(other = modifier) - .fillMaxWidth() - .testTag(rowTestTag) - .semantics { - selected = isSelected - } - .background( - color = containerColor, - shape = shape, - ) - .combinedClickable( - enabled = enabled, - onClick = { - hapticFeedback.performHapticFeedback(HapticFeedbackType.ContextClick) - onClick() - }, - onLongClick = onLongClick?.let { callback -> - { - hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) - callback() - } - }, - ) - .padding(horizontal = 16.dp, vertical = 14.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - RecipientSelectionContactAvatar( - item = item, - isSelected = isSelected, - ) - - Column( - modifier = Modifier - .padding(start = 14.dp) - .weight(weight = 1f), - verticalArrangement = Arrangement.spacedBy(space = 2.dp), - ) { - Text( - text = recipientSelectionItemDisplayName(item = item), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - style = MaterialTheme.typography.bodyLarge, - color = primaryTextColor, - ) - - item.secondaryText?.let { secondaryText -> - Text( - text = secondaryText, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - style = MaterialTheme.typography.bodyMedium, - color = secondaryTextColor, - ) - } - } - - AnimatedVisibility( - visible = showTrailingIndicator, - enter = recipientSelectionTrailingIndicatorEnterTransition(), - exit = recipientSelectionTrailingIndicatorExitTransition(), - ) { - CircularProgressIndicator( - modifier = when { - trailingIndicatorTestTag != null -> { - Modifier - .size(size = 20.dp) - .testTag(trailingIndicatorTestTag) - } - - else -> { - Modifier - .size(size = 20.dp) - } - }, - strokeWidth = 2.dp, - ) - } - } -} - -private fun recipientSelectionContactRowShape( - index: Int, - totalCount: Int, -): RoundedCornerShape { - return when { - totalCount <= 1 -> singleContactShape - index == 0 -> topContactShape - index == totalCount - 1 -> bottomContactShape - else -> middleContactShape - } -} - -@Composable -private fun RecipientSelectionContactAvatar( - item: RecipientPickerListItem, - isSelected: Boolean, -) { - val avatarScale by rememberRecipientSelectionContactAvatarScale( - isSelected = isSelected, - ) - - AnimatedContent( - targetState = isSelected, - transitionSpec = { - recipientSelectionAvatarContentTransform() - }, - label = "recipientSelectionContactAvatar", - ) { isSelectedState -> - Box( - modifier = Modifier.graphicsLayer { - scaleX = avatarScale - scaleY = avatarScale - }, - ) { - when { - isSelectedState -> { - RecipientSelectionSelectedAvatar() - } - - recipientSelectionPhotoUri(item) == null -> { - RecipientSelectionTextAvatar(item) - } - - else -> { - AsyncImage( - modifier = Modifier - .size(size = 40.dp) - .clip(shape = CircleShape), - model = recipientSelectionPhotoUri(item), - contentDescription = recipientSelectionItemDisplayName(item), - ) - } - } - } - } -} - -@Composable -private fun RecipientSelectionSelectedAvatar( - modifier: Modifier = Modifier, -) { - Box( - modifier = modifier - .size(size = 40.dp) - .background( - color = MaterialTheme.colorScheme.primary, - shape = CircleShape, - ), - contentAlignment = Alignment.Center, - ) { - Icon( - imageVector = Icons.Rounded.Check, - contentDescription = null, - tint = MaterialTheme.colorScheme.onPrimary, - ) - } -} - -@Composable -private fun RecipientSelectionTextAvatar( - item: RecipientPickerListItem, - modifier: Modifier = Modifier, -) { - val displayName = recipientSelectionItemDisplayName(item = item) - val label = remember(displayName, item.destination) { - recipientSelectionAvatarLabel( - displayName = displayName, - destination = item.destination, - ) - } - - Box( - modifier = modifier - .size(size = 40.dp) - .background( - color = MaterialTheme.colorScheme.secondaryContainer, - shape = CircleShape, - ), - contentAlignment = Alignment.Center, - ) { - Text( - text = label, - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSecondaryContainer, - ) - } -} - -@Composable -private fun recipientSelectionItemDisplayName( - item: RecipientPickerListItem, -): String { - return when (item) { - is RecipientPickerListItem.Contact -> item.recipient.displayName - is RecipientPickerListItem.SyntheticPhone -> { - stringResource( - id = R.string.contact_list_send_to_text, - item.rawQuery, - ) - } - } -} - -private fun recipientSelectionPhotoUri(item: RecipientPickerListItem): String? { - return when (item) { - is RecipientPickerListItem.Contact -> item.recipient.photoUri - is RecipientPickerListItem.SyntheticPhone -> null - } -} - -private fun recipientSelectionAvatarLabel( - displayName: String, - destination: String, -): String { - val labelSource = displayName.ifBlank { destination } - val firstCharacter = labelSource.firstOrNull() ?: '?' - - return firstCharacter.uppercaseChar().toString() -} - -private fun recipientSelectionPrimaryActionEnterTransition(): EnterTransition { - return fadeIn( - animationSpec = recipientSelectionDefaultEffectsAnimationSpec(), - ) + slideInVertically( - animationSpec = recipientSelectionSpatialAnimationSpec(), - initialOffsetY = { fullHeight -> - fullHeight / 2 - }, - ) + scaleIn( - animationSpec = recipientSelectionSpatialAnimationSpec(), - initialScale = 0.9f, - ) -} - -private fun recipientSelectionPrimaryActionExitTransition(): ExitTransition { - return fadeOut( - animationSpec = recipientSelectionFastEffectsAnimationSpec(), - ) + slideOutVertically( - animationSpec = recipientSelectionSpatialAnimationSpec(), - targetOffsetY = { fullHeight -> - fullHeight / 2 - }, - ) + scaleOut( - animationSpec = recipientSelectionSpatialAnimationSpec(), - targetScale = 0.9f, - ) -} - -private fun recipientSelectionPrimaryActionContentTransform(): ContentTransform { - return recipientSelectionFadeAndScaleContentTransform( - scale = 0.9f, - ) -} - -private fun recipientSelectionTrailingIndicatorEnterTransition(): EnterTransition { - return fadeIn( - animationSpec = recipientSelectionDefaultEffectsAnimationSpec(), - ) + scaleIn( - animationSpec = recipientSelectionSpatialAnimationSpec(), - initialScale = 0.8f, - ) -} - -private fun recipientSelectionTrailingIndicatorExitTransition(): ExitTransition { - return fadeOut( - animationSpec = recipientSelectionFastEffectsAnimationSpec(), - ) + scaleOut( - animationSpec = recipientSelectionSpatialAnimationSpec(), - targetScale = 0.8f, - ) -} - -private fun recipientSelectionAvatarContentTransform(): ContentTransform { - return recipientSelectionFadeAndScaleContentTransform( - scale = 0.8f, - ) -} - -private fun recipientSelectionFadeAndScaleContentTransform(scale: Float): ContentTransform { - val enterTransition = fadeIn( - animationSpec = recipientSelectionDefaultEffectsAnimationSpec(), - ) + scaleIn( - animationSpec = recipientSelectionSpatialAnimationSpec(), - initialScale = scale, - ) - val exitTransition = fadeOut( - animationSpec = recipientSelectionFastEffectsAnimationSpec(), - ) + scaleOut( - animationSpec = recipientSelectionSpatialAnimationSpec(), - targetScale = scale, - ) - - return enterTransition.togetherWith(exitTransition) -} - -@Composable -private fun rememberRecipientSelectionContactAvatarScale( - isSelected: Boolean, -): State { - val selectionTransition = updateTransition( - targetState = isSelected, - label = "recipientSelectionContactAvatarScale", - ) - - return selectionTransition.animateFloat( - transitionSpec = { - recipientSelectionSpatialAnimationSpec() - }, - label = "recipientSelectionContactAvatarScaleValue", - targetValueByState = { isAvatarSelected -> - when { - isAvatarSelected -> 1f - else -> 0.9f - } - }, - ) -} - -@Composable -private fun Transition.animateContainerColor(): State { - return animateColor( - transitionSpec = { - recipientSelectionSelectionAnimationSpec() - }, - label = "recipientSelectionContactContainerColor", - targetValueByState = { isContactSelected -> - when { - isContactSelected -> MaterialTheme.colorScheme.secondaryContainer - else -> MaterialTheme.colorScheme.background - } - }, - ) -} - -@Composable -private fun Transition.animatePrimaryTextColor(): State { - return animateColor( - transitionSpec = { - recipientSelectionSelectionAnimationSpec() - }, - label = "recipientSelectionContactPrimaryTextColor", - targetValueByState = { isContactSelected -> - when { - isContactSelected -> MaterialTheme.colorScheme.onSecondaryContainer - else -> MaterialTheme.colorScheme.onSurface - } - }, - ) -} - -@Composable -private fun Transition.animateSecondaryTextColor(): State { - return animateColor( - transitionSpec = { - recipientSelectionSelectionAnimationSpec() - }, - label = "recipientSelectionContactSecondaryTextColor", - targetValueByState = { isContactSelected -> - when { - isContactSelected -> { - MaterialTheme.colorScheme.onSecondaryContainer.copy(alpha = 0.7f) - } - - else -> MaterialTheme.colorScheme.onSurfaceVariant - } - }, - ) -} - -private fun recipientSelectionSelectionAnimationSpec(): FiniteAnimationSpec { - return tween( - durationMillis = 200, - easing = FastOutSlowInEasing, - ) -} - -private fun recipientSelectionDefaultEffectsAnimationSpec(): FiniteAnimationSpec { - return tween( - durationMillis = 200, - easing = LinearOutSlowInEasing, - ) -} - -private fun recipientSelectionFastEffectsAnimationSpec(): FiniteAnimationSpec { - return tween( - durationMillis = 150, - easing = FastOutSlowInEasing, - ) -} - -private fun recipientSelectionSpatialAnimationSpec(): FiniteAnimationSpec { - return spring( - dampingRatio = Spring.DampingRatioNoBouncy, - stiffness = Spring.StiffnessMediumLow, - ) -} diff --git a/src/com/android/messaging/ui/conversation/v2/recipientpicker/RecipientSelectionPrimaryActionButton.kt b/src/com/android/messaging/ui/conversation/v2/recipientpicker/RecipientSelectionPrimaryActionButton.kt new file mode 100644 index 00000000..6a03c679 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/v2/recipientpicker/RecipientSelectionPrimaryActionButton.kt @@ -0,0 +1,114 @@ +package com.android.messaging.ui.conversation.v2.recipientpicker + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.ContentTransform +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.ArrowForward +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.unit.dp + +@Composable +internal fun RecipientSelectionPrimaryActionButton( + enabled: Boolean, + isLoading: Boolean, + text: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + testTag: String? = null, +) { + val taggedModifier = when { + testTag != null -> modifier.testTag(testTag) + else -> modifier + } + + Button( + modifier = taggedModifier + .animateContentSize( + animationSpec = spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = Spring.StiffnessMediumLow, + ), + ), + onClick = onClick, + enabled = enabled, + shape = RoundedCornerShape(size = 18.dp), + ) { + AnimatedContent( + targetState = isLoading, + transitionSpec = { + recipientSelectionPrimaryActionContentTransform() + }, + label = "recipientSelectionPrimaryActionButtonContent", + ) { isButtonLoading -> + when { + isButtonLoading -> { + CircularProgressIndicator( + modifier = Modifier.size(size = 18.dp), + color = MaterialTheme.colorScheme.onPrimary, + strokeWidth = 2.dp, + ) + } + + else -> { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + Text(text = text) + + Spacer(modifier = Modifier.size(size = 8.dp)) + + Icon( + imageVector = Icons.AutoMirrored.Rounded.ArrowForward, + contentDescription = null, + ) + } + } + } + } + } +} + +private fun recipientSelectionPrimaryActionContentTransform(): ContentTransform { + return ( + fadeIn( + animationSpec = tween(durationMillis = 200), + ) + scaleIn( + animationSpec = spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = Spring.StiffnessMediumLow, + ), + initialScale = 0.9f, + ) + ).togetherWith( + fadeOut( + animationSpec = tween(durationMillis = 150), + ) + scaleOut( + animationSpec = spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = Spring.StiffnessMediumLow, + ), + targetScale = 0.9f, + ), + ) +} diff --git a/src/com/android/messaging/ui/conversation/v2/screen/ConversationViewModel.kt b/src/com/android/messaging/ui/conversation/v2/screen/ConversationViewModel.kt index fcf5f8ca..4c3a8474 100644 --- a/src/com/android/messaging/ui/conversation/v2/screen/ConversationViewModel.kt +++ b/src/com/android/messaging/ui/conversation/v2/screen/ConversationViewModel.kt @@ -34,6 +34,7 @@ import com.android.messaging.ui.conversation.v2.screen.model.ConversationMessage import com.android.messaging.ui.conversation.v2.screen.model.ConversationScreenEffect import com.android.messaging.ui.conversation.v2.screen.model.ConversationScreenScaffoldUiState import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.flow.Flow @@ -44,7 +45,6 @@ import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch -import javax.inject.Inject internal interface ConversationScreenModel { val effects: Flow @@ -262,7 +262,7 @@ internal class ConversationViewModel @Inject constructor( conversationTitle = conversationTitle, isSendActionEnabled = composerUiState.isSendEnabled, photoPickerSourceContentUriByAttachmentContentUri = - photoPickerSourceContentUriByAttachmentContentUri, + photoPickerSourceContentUriByAttachmentContentUri, ) }.stateIn( scope = viewModelScope, diff --git a/src/com/android/messaging/ui/conversation/v2/screen/model/ConversationMediaPickerOverlayUiState.kt b/src/com/android/messaging/ui/conversation/v2/screen/model/ConversationMediaPickerOverlayUiState.kt index 8096d20c..d6c570be 100644 --- a/src/com/android/messaging/ui/conversation/v2/screen/model/ConversationMediaPickerOverlayUiState.kt +++ b/src/com/android/messaging/ui/conversation/v2/screen/model/ConversationMediaPickerOverlayUiState.kt @@ -4,8 +4,8 @@ import androidx.compose.runtime.Immutable import com.android.messaging.ui.conversation.v2.composer.model.ComposerAttachmentUiModel import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableMap -import kotlinx.collections.immutable.persistentMapOf import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.persistentMapOf @Immutable internal data class ConversationMediaPickerOverlayUiState( From 597218209b6da88309eaca276d0097087e024e77 Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Thu, 30 Apr 2026 19:29:14 +0300 Subject: [PATCH 82/99] Fix LongMethod errors --- .../ui/ConversationAudioRecordingBar.kt | 96 ++-- .../v2/composer/ui/ConversationComposeBar.kt | 210 ++++--- .../ui/ConversationComposeMessageField.kt | 53 +- .../ui/conversation/v2/entry/NewChatScreen.kt | 100 ++-- .../v2/mediapicker/ConversationMediaPicker.kt | 242 ++++++-- .../ConversationMediaPickerOverlay.kt | 8 +- .../ConversationMediaPickerPermission.kt | 11 +- .../ConversationMediaPickerScaffold.kt | 3 - .../ConversationMediaCaptureShutterButton.kt | 225 +++++--- .../review/ConversationMediaPickerReview.kt | 184 ++++-- .../ConversationGenericInlineAttachmentRow.kt | 63 ++- .../ui/message/ConversationMessage.kt | 168 +++--- .../ui/message/ConversationMessageBubble.kt | 219 +++++--- .../v2/metadata/ui/ConversationTopAppBar.kt | 270 +++++---- .../v2/navigation/ConversationNavGraph.kt | 403 ++++++++------ .../v2/screen/ConversationScreen.kt | 523 +++++++----------- .../v2/screen/ConversationScreenEffects.kt | 240 ++++---- .../v2/screen/ConversationScreenRoute.kt | 309 +++++++++++ .../screen/ConversationSelectionTopAppBar.kt | 259 +++++---- .../screen/PendingAudioRecordingStartMode.kt | 7 + 20 files changed, 2254 insertions(+), 1339 deletions(-) create mode 100644 src/com/android/messaging/ui/conversation/v2/screen/ConversationScreenRoute.kt create mode 100644 src/com/android/messaging/ui/conversation/v2/screen/PendingAudioRecordingStartMode.kt diff --git a/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationAudioRecordingBar.kt b/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationAudioRecordingBar.kt index bd56a80f..f8d82ee6 100644 --- a/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationAudioRecordingBar.kt +++ b/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationAudioRecordingBar.kt @@ -302,32 +302,16 @@ internal fun ConversationAudioRecordingLockAffordance( modifier: Modifier = Modifier, lockProgress: Float, ) { - val resolvedLockProgress = lockProgress.coerceIn(minimumValue = 0f, maximumValue = 1f) - - val contentColor = animateColorAsState( - targetValue = lerp( - start = MaterialTheme.colorScheme.onSurfaceVariant, - stop = MaterialTheme.colorScheme.onSurface, - fraction = resolvedLockProgress, - ), - animationSpec = tween(durationMillis = 180), - label = "conversation_audio_lock_content_color", - ).value - - val affordanceScale = animateFloatAsState( - targetValue = 0.96f + (resolvedLockProgress * 0.06f), - animationSpec = tween(durationMillis = 180), - label = "conversation_audio_lock_scale", - ).value - - val verticalTranslation = -8f * resolvedLockProgress + val visualState = animateConversationAudioRecordingLockAffordanceVisualState( + lockProgress = lockProgress, + ) Column( modifier = modifier .graphicsLayer { - scaleX = affordanceScale - scaleY = affordanceScale - translationY = verticalTranslation + scaleX = visualState.scale + scaleY = visualState.scale + translationY = visualState.verticalTranslation } .shadow( elevation = 8.dp, @@ -350,33 +334,75 @@ internal fun ConversationAudioRecordingLockAffordance( modifier = Modifier.size(size = 18.dp), imageVector = Icons.Rounded.Lock, contentDescription = null, - tint = contentColor, + tint = visualState.contentColor, ) - Spacer( - modifier = Modifier - .padding(vertical = 4.dp) - .size( - width = 18.dp, - height = 1.dp, - ) - .background( - color = contentColor.copy(alpha = 0.2f), - shape = CircleShape, - ), + ConversationAudioRecordingLockAffordanceDivider( + color = visualState.contentColor, ) Icon( modifier = Modifier.size(size = 18.dp), imageVector = Icons.Rounded.KeyboardArrowUp, contentDescription = null, - tint = contentColor, + tint = visualState.contentColor, ) } } +@Composable +private fun animateConversationAudioRecordingLockAffordanceVisualState( + lockProgress: Float, +): ConversationAudioRecordingLockAffordanceVisualState { + val resolvedLockProgress = lockProgress.coerceIn(minimumValue = 0f, maximumValue = 1f) + + val contentColor = animateColorAsState( + targetValue = lerp( + start = MaterialTheme.colorScheme.onSurfaceVariant, + stop = MaterialTheme.colorScheme.onSurface, + fraction = resolvedLockProgress, + ), + animationSpec = tween(durationMillis = 180), + label = "conversation_audio_lock_content_color", + ).value + + val scale = animateFloatAsState( + targetValue = 0.96f + (resolvedLockProgress * 0.06f), + animationSpec = tween(durationMillis = 180), + label = "conversation_audio_lock_scale", + ).value + + return ConversationAudioRecordingLockAffordanceVisualState( + contentColor = contentColor, + scale = scale, + verticalTranslation = -8f * resolvedLockProgress, + ) +} + +@Composable +private fun ConversationAudioRecordingLockAffordanceDivider(color: Color) { + Spacer( + modifier = Modifier + .padding(vertical = 4.dp) + .size( + width = 18.dp, + height = 1.dp, + ) + .background( + color = color.copy(alpha = 0.2f), + shape = CircleShape, + ), + ) +} + private data class AudioRecordingBarVisualState( val contentColor: Color, val deleteIconTint: Color, val hintAlpha: Float, ) + +private data class ConversationAudioRecordingLockAffordanceVisualState( + val contentColor: Color, + val scale: Float, + val verticalTranslation: Float, +) diff --git a/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationComposeBar.kt b/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationComposeBar.kt index 7fe3a59e..8362a30f 100644 --- a/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationComposeBar.kt +++ b/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationComposeBar.kt @@ -2,8 +2,6 @@ package com.android.messaging.ui.conversation.v2.composer.ui import androidx.compose.animation.AnimatedContent import androidx.compose.animation.ContentTransform -import androidx.compose.animation.EnterTransition -import androidx.compose.animation.ExitTransition import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut @@ -76,17 +74,13 @@ internal fun ConversationComposeBar( onSendClick: () -> Unit, ) { val presentation = rememberConversationComposeBarPresentation() - val hapticFeedback = LocalHapticFeedback.current - - var recordingGestureState by remember { - mutableStateOf(ConversationSendActionButtonGestureState()) - } - - LaunchedEffect(audioRecording.phase) { - if (audioRecording.phase != ConversationAudioRecordingPhase.Recording) { - recordingGestureState = ConversationSendActionButtonGestureState() - } - } + val recordingGestureController = rememberConversationAudioRecordingGestureController( + audioRecording = audioRecording, + onAudioRecordingStartRequest = onAudioRecordingStartRequest, + onAudioRecordingFinish = onAudioRecordingFinish, + onAudioRecordingLock = onAudioRecordingLock, + onAudioRecordingCancel = onAudioRecordingCancel, + ) Box( modifier = modifier @@ -104,44 +98,75 @@ internal fun ConversationComposeBar( isRecordActionEnabled = isRecordActionEnabled, isSendActionEnabled = isSendActionEnabled, shouldShowRecordAction = shouldShowRecordAction, - recordingGestureState = recordingGestureState, + recordingGestureState = recordingGestureController.recordingGestureState, messageFieldFocusRequester = messageFieldFocusRequester, presentation = presentation, onContactAttachClick = onContactAttachClick, onMediaPickerClick = onMediaPickerClick, onLockedAudioRecordingStartRequest = onLockedAudioRecordingStartRequest, onMessageTextChange = onMessageTextChange, - onAudioRecordingStartRequest = { - recordingGestureState = ConversationSendActionButtonGestureState() - onAudioRecordingStartRequest() - }, - onAudioRecordingDrag = { gestureState -> - recordingGestureState = gestureState - }, - onAudioRecordingLock = { - if (audioRecording.isLocked) { - return@ConversationComposeInputContent false - } - - recordingGestureState = ConversationSendActionButtonGestureState() - val didLockRecording = onAudioRecordingLock() - if (didLockRecording) { - hapticFeedback.performHapticFeedback(HapticFeedbackType.Confirm) - } - didLockRecording - }, - onAudioRecordingFinish = { shouldCancelRecording -> - recordingGestureState = ConversationSendActionButtonGestureState() - when { - shouldCancelRecording -> onAudioRecordingCancel() - else -> onAudioRecordingFinish() - } - }, + onAudioRecordingStartRequest = recordingGestureController.onAudioRecordingStartRequest, + onAudioRecordingDrag = recordingGestureController.onAudioRecordingDrag, + onAudioRecordingLock = recordingGestureController.onAudioRecordingLock, + onAudioRecordingFinish = recordingGestureController.onAudioRecordingFinish, onSendClick = onSendClick, ) } } +@Composable +private fun rememberConversationAudioRecordingGestureController( + audioRecording: ConversationAudioRecordingUiState, + onAudioRecordingStartRequest: () -> Unit, + onAudioRecordingFinish: () -> Unit, + onAudioRecordingLock: () -> Boolean, + onAudioRecordingCancel: () -> Unit, +): ConversationAudioRecordingGestureController { + val hapticFeedback = LocalHapticFeedback.current + + var recordingGestureState by remember { + mutableStateOf(ConversationSendActionButtonGestureState()) + } + + LaunchedEffect(audioRecording.phase) { + if (audioRecording.phase != ConversationAudioRecordingPhase.Recording) { + recordingGestureState = ConversationSendActionButtonGestureState() + } + } + + return ConversationAudioRecordingGestureController( + recordingGestureState = recordingGestureState, + onAudioRecordingStartRequest = { + recordingGestureState = ConversationSendActionButtonGestureState() + onAudioRecordingStartRequest() + }, + onAudioRecordingDrag = { gestureState -> + recordingGestureState = gestureState + }, + onAudioRecordingLock = { + when { + audioRecording.isLocked -> false + + else -> { + recordingGestureState = ConversationSendActionButtonGestureState() + val didLockRecording = onAudioRecordingLock() + if (didLockRecording) { + hapticFeedback.performHapticFeedback(HapticFeedbackType.Confirm) + } + didLockRecording + } + } + }, + onAudioRecordingFinish = { shouldCancelRecording -> + recordingGestureState = ConversationSendActionButtonGestureState() + when { + shouldCancelRecording -> onAudioRecordingCancel() + else -> onAudioRecordingFinish() + } + }, + ) +} + @Composable internal fun ConversationComposeInputContent( audioRecording: ConversationAudioRecordingUiState, @@ -202,33 +227,55 @@ internal fun ConversationComposeInputContent( onMessageTextChange = onMessageTextChange, ) - ConversationComposeSendAction( - modifier = Modifier - .testTag(CONVERSATION_SEND_BUTTON_TEST_TAG) - .semantics { - conversationShape = CONVERSATION_SEND_BUTTON_SHAPE_CIRCLE - }, - enabled = inputState.isRecordingControlEnabled, - mode = conversationComposeSendActionMode( - isRecordMode = inputState.isRecordMode, - isRecordingLocked = audioRecording.isLocked, - ), - isRecordingActive = inputState.isActiveRecording, - isRecordingLocked = audioRecording.isLocked, - shouldShowLockAffordance = inputState.isActiveRecording && !audioRecording.isLocked, - lockProgress = inputState.lockProgress, - onClick = onSendClick, - onLockedStopClick = { - onAudioRecordingFinish(false) - }, - onRecordGestureStart = onAudioRecordingStartRequest, - onRecordGestureMove = onAudioRecordingDrag, - onRecordGestureLock = onAudioRecordingLock, - onRecordGestureFinish = onAudioRecordingFinish, + ConversationComposeInputSendAction( + audioRecording = audioRecording, + inputState = inputState, + onSendClick = onSendClick, + onAudioRecordingStartRequest = onAudioRecordingStartRequest, + onAudioRecordingDrag = onAudioRecordingDrag, + onAudioRecordingLock = onAudioRecordingLock, + onAudioRecordingFinish = onAudioRecordingFinish, ) } } +@Composable +private fun ConversationComposeInputSendAction( + modifier: Modifier = Modifier, + audioRecording: ConversationAudioRecordingUiState, + inputState: ConversationComposeInputState, + onSendClick: () -> Unit, + onAudioRecordingStartRequest: () -> Unit, + onAudioRecordingDrag: (ConversationSendActionButtonGestureState) -> Unit, + onAudioRecordingLock: () -> Boolean, + onAudioRecordingFinish: (Boolean) -> Unit, +) { + ConversationComposeSendAction( + modifier = modifier + .testTag(CONVERSATION_SEND_BUTTON_TEST_TAG) + .semantics { + conversationShape = CONVERSATION_SEND_BUTTON_SHAPE_CIRCLE + }, + enabled = inputState.isRecordingControlEnabled, + mode = conversationComposeSendActionMode( + isRecordMode = inputState.isRecordMode, + isRecordingLocked = audioRecording.isLocked, + ), + isRecordingActive = inputState.isActiveRecording, + isRecordingLocked = audioRecording.isLocked, + shouldShowLockAffordance = inputState.isActiveRecording && !audioRecording.isLocked, + lockProgress = inputState.lockProgress, + onClick = onSendClick, + onLockedStopClick = { + onAudioRecordingFinish(false) + }, + onRecordGestureStart = onAudioRecordingStartRequest, + onRecordGestureMove = onAudioRecordingDrag, + onRecordGestureLock = onAudioRecordingLock, + onRecordGestureFinish = onAudioRecordingFinish, + ) +} + @Composable private fun conversationComposeInputState( audioRecording: ConversationAudioRecordingUiState, @@ -372,36 +419,25 @@ private fun conversationComposeSendActionMode( } private fun contentSwapTransition(): ContentTransform { - val enterTransition = contentSwapEnterTransition() - val exitTransition = contentSwapExitTransition() - - return enterTransition.togetherWith(exitTransition) -} - -private fun contentSwapEnterTransition(): EnterTransition { - return fadeIn( + val enterTransition = fadeIn( animationSpec = tween(durationMillis = CONTENT_SWAP_ENTER_FADE_DURATION_MILLIS), ) + slideInHorizontally( animationSpec = tween(durationMillis = CONTENT_SWAP_ENTER_SLIDE_DURATION_MILLIS), - initialOffsetX = ::contentSwapEnterOffset, + initialOffsetX = { fullWidth -> + fullWidth / CONTENT_SWAP_ENTER_SLIDE_OFFSET_DIVISOR + }, ) -} -private fun contentSwapExitTransition(): ExitTransition { - return fadeOut( + val exitTransition = fadeOut( animationSpec = tween(durationMillis = CONTENT_SWAP_EXIT_FADE_DURATION_MILLIS), ) + slideOutHorizontally( animationSpec = tween(durationMillis = CONTENT_SWAP_EXIT_SLIDE_DURATION_MILLIS), - targetOffsetX = ::contentSwapExitOffset, + targetOffsetX = { fullWidth -> + -(fullWidth / CONTENT_SWAP_EXIT_SLIDE_OFFSET_DIVISOR) + }, ) -} -private fun contentSwapEnterOffset(fullWidth: Int): Int { - return fullWidth / CONTENT_SWAP_ENTER_SLIDE_OFFSET_DIVISOR -} - -private fun contentSwapExitOffset(fullWidth: Int): Int { - return -(fullWidth / CONTENT_SWAP_EXIT_SLIDE_OFFSET_DIVISOR) + return enterTransition.togetherWith(exitTransition) } @Composable @@ -460,3 +496,11 @@ private data class ConversationComposeInputState( val isRecordMode: Boolean, val isRecordingControlEnabled: Boolean, ) + +private data class ConversationAudioRecordingGestureController( + val recordingGestureState: ConversationSendActionButtonGestureState, + val onAudioRecordingStartRequest: () -> Unit, + val onAudioRecordingDrag: (ConversationSendActionButtonGestureState) -> Unit, + val onAudioRecordingLock: () -> Boolean, + val onAudioRecordingFinish: (Boolean) -> Unit, +) diff --git a/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationComposeMessageField.kt b/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationComposeMessageField.kt index cfac0226..37dd1aab 100644 --- a/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationComposeMessageField.kt +++ b/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationComposeMessageField.kt @@ -243,29 +243,15 @@ private fun ConversationComposeAttachmentMenu( focusable = false, ), ) { - ConversationComposeAttachmentMenuItem( - modifier = Modifier.testTag(CONVERSATION_ATTACHMENT_MEDIA_MENU_ITEM_TEST_TAG), - imageVector = Icons.Rounded.Image, - textResId = R.string.mediapicker_gallery_title, - onClick = { + ConversationComposeAttachmentMenuContent( + isAudioRecordActionEnabled = isAudioRecordActionEnabled, + onMediaPickerClick = { closeMenuAndRun(action = onMediaPickerClick) }, - ) - ConversationComposeAttachmentMenuItem( - modifier = Modifier.testTag(CONVERSATION_ATTACHMENT_AUDIO_MENU_ITEM_TEST_TAG), - imageVector = Icons.Rounded.Mic, - textResId = R.string.mediapicker_audio_title, - enabled = isAudioRecordActionEnabled, - onClick = { + onAudioAttachClick = { closeMenuAndRun(action = onAudioAttachClick) }, - ) - - ConversationComposeAttachmentMenuItem( - modifier = Modifier.testTag(CONVERSATION_ATTACHMENT_CONTACT_MENU_ITEM_TEST_TAG), - imageVector = Icons.Rounded.Person, - textResId = R.string.mediapicker_contact_title, - onClick = { + onContactAttachClick = { closeMenuAndRun(action = onContactAttachClick) }, ) @@ -273,6 +259,35 @@ private fun ConversationComposeAttachmentMenu( } } +@Composable +private fun ConversationComposeAttachmentMenuContent( + isAudioRecordActionEnabled: Boolean, + onMediaPickerClick: () -> Unit, + onAudioAttachClick: () -> Unit, + onContactAttachClick: () -> Unit, +) { + ConversationComposeAttachmentMenuItem( + modifier = Modifier.testTag(CONVERSATION_ATTACHMENT_MEDIA_MENU_ITEM_TEST_TAG), + imageVector = Icons.Rounded.Image, + textResId = R.string.mediapicker_gallery_title, + onClick = onMediaPickerClick, + ) + ConversationComposeAttachmentMenuItem( + modifier = Modifier.testTag(CONVERSATION_ATTACHMENT_AUDIO_MENU_ITEM_TEST_TAG), + imageVector = Icons.Rounded.Mic, + textResId = R.string.mediapicker_audio_title, + enabled = isAudioRecordActionEnabled, + onClick = onAudioAttachClick, + ) + + ConversationComposeAttachmentMenuItem( + modifier = Modifier.testTag(CONVERSATION_ATTACHMENT_CONTACT_MENU_ITEM_TEST_TAG), + imageVector = Icons.Rounded.Person, + textResId = R.string.mediapicker_contact_title, + onClick = onContactAttachClick, + ) +} + @Composable private fun ConversationComposeAttachmentMenuItem( modifier: Modifier = Modifier, diff --git a/src/com/android/messaging/ui/conversation/v2/entry/NewChatScreen.kt b/src/com/android/messaging/ui/conversation/v2/entry/NewChatScreen.kt index bade6d54..200a5045 100644 --- a/src/com/android/messaging/ui/conversation/v2/entry/NewChatScreen.kt +++ b/src/com/android/messaging/ui/conversation/v2/entry/NewChatScreen.kt @@ -148,28 +148,13 @@ private fun NewChatRecipientSelectionContent( onCreateGroupRecipientClick: (String) -> Unit, modifier: Modifier = Modifier, ) { - val primaryAction = when { - isCreatingGroup && selectedGroupRecipientDestinations.isNotEmpty() -> { - RecipientSelectionPrimaryActionUiState( - text = stringResource(id = R.string.next), - isEnabled = !pickerUiState.isLoading && !isResolvingConversation, - isLoading = isResolvingConversationIndicatorVisible, - testTag = NEW_CHAT_CREATE_GROUP_NEXT_BUTTON_TEST_TAG, - ) - } - - else -> null - } - RecipientSelectionContent( - uiState = RecipientSelectionContentUiState( - picker = pickerUiState, - primaryAction = primaryAction, - selectedRecipientDestinations = when { - isCreatingGroup -> selectedGroupRecipientDestinations.toImmutableSet() - else -> persistentSetOf() - }, - isQueryEnabled = !isResolvingConversation, + uiState = newChatRecipientSelectionContentUiState( + pickerUiState = pickerUiState, + isCreatingGroup = isCreatingGroup, + isResolvingConversation = isResolvingConversation, + isResolvingConversationIndicatorVisible = isResolvingConversationIndicatorVisible, + selectedGroupRecipientDestinations = selectedGroupRecipientDestinations, ), strings = RecipientSelectionStrings( queryPrefixText = stringResource(id = R.string.new_chat_recipient_prefix), @@ -213,25 +198,68 @@ private fun NewChatRecipientSelectionContent( } }, topListContent = { - AnimatedVisibility( - visible = !isCreatingGroup, - enter = newGroupButtonEnterTransition(), - exit = newGroupButtonExitTransition(), - ) { - Column( - verticalArrangement = Arrangement.spacedBy(space = 12.dp), - ) { - NewGroupButton( - modifier = Modifier.fillMaxWidth(), - onClick = onCreateGroupClick, - ) - Spacer(modifier = Modifier.height(height = 12.dp)) - } - } + NewChatRecipientSelectionTopListContent( + isCreatingGroup = isCreatingGroup, + onCreateGroupClick = onCreateGroupClick, + ) + }, + ) +} + +@Composable +private fun newChatRecipientSelectionContentUiState( + pickerUiState: RecipientPickerUiState, + isCreatingGroup: Boolean, + isResolvingConversation: Boolean, + isResolvingConversationIndicatorVisible: Boolean, + selectedGroupRecipientDestinations: ImmutableList, +): RecipientSelectionContentUiState { + val primaryAction = when { + isCreatingGroup && selectedGroupRecipientDestinations.isNotEmpty() -> { + RecipientSelectionPrimaryActionUiState( + text = stringResource(id = R.string.next), + isEnabled = !pickerUiState.isLoading && !isResolvingConversation, + isLoading = isResolvingConversationIndicatorVisible, + testTag = NEW_CHAT_CREATE_GROUP_NEXT_BUTTON_TEST_TAG, + ) + } + + else -> null + } + + return RecipientSelectionContentUiState( + picker = pickerUiState, + primaryAction = primaryAction, + selectedRecipientDestinations = when { + isCreatingGroup -> selectedGroupRecipientDestinations.toImmutableSet() + else -> persistentSetOf() }, + isQueryEnabled = !isResolvingConversation, ) } +@Composable +private fun NewChatRecipientSelectionTopListContent( + isCreatingGroup: Boolean, + onCreateGroupClick: () -> Unit, +) { + AnimatedVisibility( + visible = !isCreatingGroup, + enter = newGroupButtonEnterTransition(), + exit = newGroupButtonExitTransition(), + ) { + Column( + verticalArrangement = Arrangement.spacedBy(space = 12.dp), + ) { + NewGroupButton( + modifier = Modifier.fillMaxWidth(), + onClick = onCreateGroupClick, + ) + Spacer(modifier = Modifier.height(height = 12.dp)) + } + } +} + @Composable private fun newChatTitle( isCreatingGroup: Boolean, diff --git a/src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationMediaPicker.kt b/src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationMediaPicker.kt index 2097245f..17bf4deb 100644 --- a/src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationMediaPicker.kt +++ b/src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationMediaPicker.kt @@ -1,5 +1,6 @@ package com.android.messaging.ui.conversation.v2.mediapicker +import android.annotation.SuppressLint import android.net.Uri import android.os.Build import android.provider.MediaStore @@ -7,8 +8,10 @@ import android.widget.photopicker.EmbeddedPhotoPickerFeatureInfo import androidx.annotation.RequiresExtension import androidx.compose.foundation.background import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.BottomSheetScaffoldState import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SheetState import androidx.compose.material3.SheetValue import androidx.compose.material3.rememberBottomSheetScaffoldState import androidx.compose.material3.rememberStandardBottomSheetState @@ -21,10 +24,12 @@ import androidx.compose.ui.Modifier import androidx.core.net.toUri import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.photopicker.compose.EmbeddedPhotoPicker +import androidx.photopicker.compose.EmbeddedPhotoPickerState import androidx.photopicker.compose.ExperimentalPhotoPickerComposeApi import androidx.photopicker.compose.rememberEmbeddedPhotoPickerState import com.android.messaging.ui.conversation.v2.composer.model.ComposerAttachmentUiModel import com.android.messaging.ui.conversation.v2.mediapicker.camera.BindConversationCameraLifecycleEffect +import com.android.messaging.ui.conversation.v2.mediapicker.camera.ConversationCameraController import com.android.messaging.ui.conversation.v2.mediapicker.camera.rememberConversationCameraController import com.android.messaging.ui.conversation.v2.mediapicker.model.ConversationCapturedMedia import com.android.messaging.util.ContentType @@ -32,6 +37,7 @@ import com.android.messaging.util.LogUtil import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableMap import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.launch @@ -39,7 +45,6 @@ import kotlinx.coroutines.launch private const val TAG = "ConversationMediaPicker" @OptIn(ExperimentalMaterial3Api::class, ExperimentalPhotoPickerComposeApi::class) -@RequiresExtension(extension = Build.VERSION_CODES.UPSIDE_DOWN_CAKE, version = 15) @Composable internal fun ConversationMediaPicker( modifier: Modifier = Modifier, @@ -62,27 +67,58 @@ internal fun ConversationMediaPicker( onSendClick: () -> Unit, ) { val cameraController = rememberConversationCameraController() + val visualAttachments = rememberVisualMediaAttachments(attachments = attachments) + val isReviewVisible = state.isReviewRequested && visualAttachments.isNotEmpty() val lifecycleOwner = LocalLifecycleOwner.current - val coroutineScope = rememberCoroutineScope() - val visualAttachments = remember(attachments) { + BindConversationCameraLifecycleEffect( + cameraController = cameraController, + cameraPermissionGranted = cameraPermissionGranted, + isCameraPreviewVisible = !isReviewVisible, + lifecycleOwner = lifecycleOwner, + ) + + ConversationMediaPickerContent( + modifier = modifier, + cameraController = cameraController, + visualAttachments = visualAttachments, + isReviewVisible = isReviewVisible, + state = state, + conversationTitle = conversationTitle, + isSendActionEnabled = isSendActionEnabled, + cameraPermissionGranted = cameraPermissionGranted, + audioPermissionGranted = audioPermissionGranted, + onClose = onClose, + onAttachmentPreviewClick = onAttachmentPreviewClick, + onAttachmentCaptionChange = onAttachmentCaptionChange, + onAttachmentRemove = onAttachmentRemove, + photoPickerSourceContentUriByAttachmentContentUri = + photoPickerSourceContentUriByAttachmentContentUri, + onPhotoPickerMediaSelected = onPhotoPickerMediaSelected, + onPhotoPickerMediaDeselected = onPhotoPickerMediaDeselected, + onRequestAudioPermission = onRequestAudioPermission, + onRequestCameraPermission = onRequestCameraPermission, + onCapturedMediaReady = onCapturedMediaReady, + onSendClick = onSendClick, + ) +} + +@Composable +private fun rememberVisualMediaAttachments( + attachments: ImmutableList, +): ImmutableList { + return remember(attachments) { attachments .asSequence() .filterIsInstance() .toImmutableList() } +} - val isReviewVisible = state.isReviewRequested && visualAttachments.isNotEmpty() - val sheetState = rememberStandardBottomSheetState( - initialValue = SheetValue.PartiallyExpanded, - skipHiddenState = true, - ) - - val scaffoldState = rememberBottomSheetScaffoldState( - bottomSheetState = sheetState, - ) - - val embeddedPhotoPickerFeatureInfo = remember { +@RequiresExtension(extension = Build.VERSION_CODES.UPSIDE_DOWN_CAKE, version = 15) +@Composable +private fun rememberConversationEmbeddedPhotoPickerFeatureInfo(): EmbeddedPhotoPickerFeatureInfo { + return remember { EmbeddedPhotoPickerFeatureInfo.Builder() .setMaxSelectionLimit(MediaStore.getPickImagesMaxLimit()) .setMimeTypes( @@ -94,8 +130,18 @@ internal fun ConversationMediaPicker( .setOrderedSelection(true) .build() } +} - val embeddedPhotoPickerState = rememberEmbeddedPhotoPickerState( +@OptIn(ExperimentalMaterial3Api::class, ExperimentalPhotoPickerComposeApi::class) +@Composable +private fun rememberConversationEmbeddedPhotoPickerState( + sheetState: SheetState, + state: ConversationMediaPickerState, + coroutineScope: CoroutineScope, + onPhotoPickerMediaSelected: (List) -> Unit, + onPhotoPickerMediaDeselected: (List) -> Unit, +): EmbeddedPhotoPickerState { + return rememberEmbeddedPhotoPickerState( initialExpandedValue = false, onSessionError = { LogUtil.w(TAG, "Embedded photo picker session failed", it) @@ -114,7 +160,14 @@ internal fun ConversationMediaPicker( } }, ) +} +@OptIn(ExperimentalMaterial3Api::class, ExperimentalPhotoPickerComposeApi::class) +@Composable +private fun SyncEmbeddedPhotoPickerExpansionEffect( + sheetState: SheetState, + embeddedPhotoPickerState: EmbeddedPhotoPickerState, +) { LaunchedEffect(sheetState, embeddedPhotoPickerState) { snapshotFlow { sheetState.currentValue == SheetValue.Expanded || @@ -125,36 +178,145 @@ internal fun ConversationMediaPicker( embeddedPhotoPickerState.setCurrentExpanded(expanded = isExpanded) } } +} - val onPickerBackedAttachmentRemove = { contentUri: String -> - val sourceContentUri = photoPickerSourceContentUriByAttachmentContentUri[contentUri] - ?: contentUri - coroutineScope.launch(Dispatchers.Main.immediate) { - try { - embeddedPhotoPickerState.deselectUri(uri = sourceContentUri.toUri()) - } catch (e: IllegalStateException) { - LogUtil.w(TAG, "Unable to deselect photo picker URI $sourceContentUri", e) +@OptIn(ExperimentalPhotoPickerComposeApi::class) +@Composable +private fun rememberPickerBackedAttachmentRemoveCallback( + coroutineScope: CoroutineScope, + embeddedPhotoPickerState: EmbeddedPhotoPickerState, + photoPickerSourceContentUriByAttachmentContentUri: ImmutableMap, + onAttachmentRemove: (String) -> Unit, +): (String) -> Unit { + return remember( + coroutineScope, + embeddedPhotoPickerState, + photoPickerSourceContentUriByAttachmentContentUri, + onAttachmentRemove, + ) { + { contentUri -> + val sourceContentUri = photoPickerSourceContentUriByAttachmentContentUri[contentUri] + ?: contentUri + coroutineScope.launch(Dispatchers.Main.immediate) { + try { + embeddedPhotoPickerState.deselectUri(uri = sourceContentUri.toUri()) + } catch (e: IllegalStateException) { + LogUtil.w(TAG, "Unable to deselect photo picker URI $sourceContentUri", e) + } } + onAttachmentRemove(contentUri) } - onAttachmentRemove(contentUri) } +} - BindConversationCameraLifecycleEffect( +@OptIn(ExperimentalMaterial3Api::class, ExperimentalPhotoPickerComposeApi::class) +@Composable +private fun ConversationMediaPickerContent( + modifier: Modifier, + cameraController: ConversationCameraController, + visualAttachments: ImmutableList, + isReviewVisible: Boolean, + state: ConversationMediaPickerState, + conversationTitle: String?, + isSendActionEnabled: Boolean, + cameraPermissionGranted: Boolean, + audioPermissionGranted: Boolean, + onClose: () -> Unit, + onAttachmentPreviewClick: (ComposerAttachmentUiModel.Resolved.VisualMedia) -> Unit, + onAttachmentCaptionChange: (String, String) -> Unit, + onAttachmentRemove: (String) -> Unit, + photoPickerSourceContentUriByAttachmentContentUri: ImmutableMap, + onPhotoPickerMediaSelected: (List) -> Unit, + onPhotoPickerMediaDeselected: (List) -> Unit, + onRequestAudioPermission: () -> Unit, + onRequestCameraPermission: () -> Unit, + onCapturedMediaReady: (ConversationCapturedMedia) -> Unit, + onSendClick: () -> Unit, +) { + val coroutineScope = rememberCoroutineScope() + val sheetState = rememberStandardBottomSheetState( + initialValue = SheetValue.PartiallyExpanded, + skipHiddenState = true, + ) + val scaffoldState = rememberBottomSheetScaffoldState( + bottomSheetState = sheetState, + ) + val embeddedPhotoPickerState = rememberConversationEmbeddedPhotoPickerState( + sheetState = sheetState, + state = state, + coroutineScope = coroutineScope, + onPhotoPickerMediaSelected = onPhotoPickerMediaSelected, + onPhotoPickerMediaDeselected = onPhotoPickerMediaDeselected, + ) + SyncEmbeddedPhotoPickerExpansionEffect( + sheetState = sheetState, + embeddedPhotoPickerState = embeddedPhotoPickerState, + ) + + ConversationMediaPickerScaffoldContent( + modifier = modifier, cameraController = cameraController, + scaffoldState = scaffoldState, + embeddedPhotoPickerState = embeddedPhotoPickerState, + embeddedPhotoPickerFeatureInfo = rememberConversationEmbeddedPhotoPickerFeatureInfo(), + visualAttachments = visualAttachments, + isReviewVisible = isReviewVisible, + state = state, + conversationTitle = conversationTitle, + isSendActionEnabled = isSendActionEnabled, cameraPermissionGranted = cameraPermissionGranted, - isCameraPreviewVisible = !isReviewVisible, - lifecycleOwner = lifecycleOwner, + audioPermissionGranted = audioPermissionGranted, + onClose = onClose, + onAttachmentPreviewClick = onAttachmentPreviewClick, + onAttachmentCaptionChange = onAttachmentCaptionChange, + onAttachmentRemove = rememberPickerBackedAttachmentRemoveCallback( + coroutineScope = coroutineScope, + embeddedPhotoPickerState = embeddedPhotoPickerState, + photoPickerSourceContentUriByAttachmentContentUri = + photoPickerSourceContentUriByAttachmentContentUri, + onAttachmentRemove = onAttachmentRemove, + ), + photoPickerSourceContentUriByAttachmentContentUri = + photoPickerSourceContentUriByAttachmentContentUri, + onRequestAudioPermission = onRequestAudioPermission, + onRequestCameraPermission = onRequestCameraPermission, + onCapturedMediaReady = onCapturedMediaReady, + onSendClick = onSendClick, ) +} +@Suppress("ParamsComparedByRef") +@OptIn(ExperimentalMaterial3Api::class, ExperimentalPhotoPickerComposeApi::class) +@Composable +private fun ConversationMediaPickerScaffoldContent( + modifier: Modifier, + cameraController: ConversationCameraController, + scaffoldState: BottomSheetScaffoldState, + embeddedPhotoPickerState: EmbeddedPhotoPickerState, + embeddedPhotoPickerFeatureInfo: EmbeddedPhotoPickerFeatureInfo, + visualAttachments: ImmutableList, + isReviewVisible: Boolean, + state: ConversationMediaPickerState, + conversationTitle: String?, + isSendActionEnabled: Boolean, + cameraPermissionGranted: Boolean, + audioPermissionGranted: Boolean, + onClose: () -> Unit, + onAttachmentPreviewClick: (ComposerAttachmentUiModel.Resolved.VisualMedia) -> Unit, + onAttachmentCaptionChange: (String, String) -> Unit, + onAttachmentRemove: (String) -> Unit, + photoPickerSourceContentUriByAttachmentContentUri: ImmutableMap, + onRequestAudioPermission: () -> Unit, + onRequestCameraPermission: () -> Unit, + onCapturedMediaReady: (ConversationCapturedMedia) -> Unit, + onSendClick: () -> Unit, +) { ConversationMediaPickerScaffold( modifier = modifier, cameraController = cameraController, scaffoldState = scaffoldState, photoPickerSheetContent = { - EmbeddedPhotoPicker( - modifier = Modifier - .background(color = MaterialTheme.colorScheme.surface) - .fillMaxSize(), + ConversationEmbeddedPhotoPickerContent( state = embeddedPhotoPickerState, embeddedPhotoPickerFeatureInfo = embeddedPhotoPickerFeatureInfo, ) @@ -171,7 +333,7 @@ internal fun ConversationMediaPicker( onClose = onClose, onAttachmentPreviewClick = onAttachmentPreviewClick, onAttachmentCaptionChange = onAttachmentCaptionChange, - onAttachmentRemove = onPickerBackedAttachmentRemove, + onAttachmentRemove = onAttachmentRemove, photoPickerSourceContentUriByAttachmentContentUri = photoPickerSourceContentUriByAttachmentContentUri, onRequestAudioPermission = onRequestAudioPermission, @@ -183,3 +345,19 @@ internal fun ConversationMediaPicker( onCaptureModeChange = state::updateCaptureMode, ) } + +@SuppressLint("NewApi") +@OptIn(ExperimentalPhotoPickerComposeApi::class) +@Composable +private fun ConversationEmbeddedPhotoPickerContent( + state: EmbeddedPhotoPickerState, + embeddedPhotoPickerFeatureInfo: EmbeddedPhotoPickerFeatureInfo, +) { + EmbeddedPhotoPicker( + modifier = Modifier + .background(color = MaterialTheme.colorScheme.surface) + .fillMaxSize(), + state = state, + embeddedPhotoPickerFeatureInfo = embeddedPhotoPickerFeatureInfo, + ) +} diff --git a/src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationMediaPickerOverlay.kt b/src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationMediaPickerOverlay.kt index 4420693b..b9f83991 100644 --- a/src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationMediaPickerOverlay.kt +++ b/src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationMediaPickerOverlay.kt @@ -1,11 +1,9 @@ package com.android.messaging.ui.conversation.v2.mediapicker import android.Manifest -import android.os.Build import androidx.activity.compose.BackHandler import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts -import androidx.annotation.RequiresExtension import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize @@ -13,7 +11,6 @@ import androidx.compose.foundation.layout.isImeVisible import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.testTag @@ -23,7 +20,6 @@ import com.android.messaging.ui.conversation.v2.mediapicker.model.ConversationCa import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableMap -@RequiresExtension(extension = Build.VERSION_CODES.UPSIDE_DOWN_CAKE, version = 15) @OptIn(ExperimentalLayoutApi::class) @Composable internal fun ConversationMediaPickerOverlay( @@ -42,12 +38,11 @@ internal fun ConversationMediaPickerOverlay( onCapturedMediaReady: (ConversationCapturedMedia) -> Unit, onSendClick: () -> Unit, ) { - val context = LocalContext.current val focusManager = LocalFocusManager.current val isImeVisible = WindowInsets.isImeVisible val keyboardController = LocalSoftwareKeyboardController.current - val permissionState = rememberConversationMediaPickerPermissionState(context = context) + val permissionState = rememberConversationMediaPickerPermissionState() val audioPermissionLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.RequestPermission(), @@ -70,7 +65,6 @@ internal fun ConversationMediaPickerOverlay( ) RefreshConversationMediaPickerPermissionsEffect( - context = context, permissionState = permissionState, ) diff --git a/src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationMediaPickerPermission.kt b/src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationMediaPickerPermission.kt index f9e30317..ebc54f13 100644 --- a/src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationMediaPickerPermission.kt +++ b/src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationMediaPickerPermission.kt @@ -12,6 +12,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.focus.FocusManager import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.SoftwareKeyboardController import androidx.core.content.ContextCompat import androidx.lifecycle.Lifecycle @@ -31,9 +32,10 @@ internal class ConversationMediaPickerPermissionState( } @Composable -internal fun rememberConversationMediaPickerPermissionState( - context: Context, -): ConversationMediaPickerPermissionState { +internal fun rememberConversationMediaPickerPermissionState(): + ConversationMediaPickerPermissionState { + val context = LocalContext.current + return remember(context) { ConversationMediaPickerPermissionState( context = context, @@ -43,9 +45,10 @@ internal fun rememberConversationMediaPickerPermissionState( @Composable internal fun RefreshConversationMediaPickerPermissionsEffect( - context: Context, permissionState: ConversationMediaPickerPermissionState, ) { + val context = LocalContext.current + LifecycleEventEffect(event = Lifecycle.Event.ON_RESUME) { permissionState.refresh(context = context) } diff --git a/src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationMediaPickerScaffold.kt b/src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationMediaPickerScaffold.kt index b494758a..3d2006b6 100644 --- a/src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationMediaPickerScaffold.kt +++ b/src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationMediaPickerScaffold.kt @@ -1,7 +1,5 @@ package com.android.messaging.ui.conversation.v2.mediapicker -import android.os.Build -import androidx.annotation.RequiresExtension import androidx.compose.animation.AnimatedContent import androidx.compose.animation.ContentTransform import androidx.compose.animation.core.Spring @@ -31,7 +29,6 @@ private enum class ConversationMediaPickerOverlayMode { } @OptIn(ExperimentalMaterial3Api::class) -@RequiresExtension(extension = Build.VERSION_CODES.UPSIDE_DOWN_CAKE, version = 15) @Composable internal fun ConversationMediaPickerScaffold( modifier: Modifier = Modifier, diff --git a/src/com/android/messaging/ui/conversation/v2/mediapicker/component/capture/ConversationMediaCaptureShutterButton.kt b/src/com/android/messaging/ui/conversation/v2/mediapicker/component/capture/ConversationMediaCaptureShutterButton.kt index 2ff4a8e7..6d21c96b 100644 --- a/src/com/android/messaging/ui/conversation/v2/mediapicker/component/capture/ConversationMediaCaptureShutterButton.kt +++ b/src/com/android/messaging/ui/conversation/v2/mediapicker/component/capture/ConversationMediaCaptureShutterButton.kt @@ -1,6 +1,7 @@ package com.android.messaging.ui.conversation.v2.mediapicker.component.capture import androidx.compose.animation.animateColor +import androidx.compose.animation.core.Transition import androidx.compose.animation.core.animateDp import androidx.compose.animation.core.animateFloat import androidx.compose.animation.core.spring @@ -43,25 +44,6 @@ private val PICKER_SHUTTER_FLOAT_SPRING_ANIMATION_SPEC = spring( stiffness = PICKER_SHUTTER_STATE_TRANSITION_SPRING_STIFFNESS, ) -private enum class ConversationMediaCaptureShutterPhase { - Photo, - VideoIdle, - VideoRecording, -} - -private data class ConversationMediaCaptureShutterVisualState( - val innerShutterColor: Color, - val innerShutterSize: Dp, - val outerContainerColor: Color, - val outerScale: Float, - val recordingStopAlpha: Float, - val recordingStopBackgroundColor: Color, - val recordingStopScale: Float, - val videoCenterDotAlpha: Float, - val videoCenterDotColor: Color, - val videoCenterDotScale: Float, -) - @Composable internal fun ConversationMediaCaptureShutterButton( captureMode: ConversationCaptureMode, @@ -90,7 +72,7 @@ private fun ConversationMediaCaptureShutterButtonAnimatedContent( onClick: () -> Unit, shutterPhase: ConversationMediaCaptureShutterPhase, ) { - val visualState = animateConversationMediaCaptureShutterVisualState( + val visualState = animateShutterVisualState( colorScheme = colorScheme, shutterPhase = shutterPhase, ) @@ -121,7 +103,7 @@ private fun ConversationMediaCaptureShutterButtonAnimatedContent( } @Composable -private fun animateConversationMediaCaptureShutterVisualState( +private fun animateShutterVisualState( colorScheme: ColorScheme, shutterPhase: ConversationMediaCaptureShutterPhase, ): ConversationMediaCaptureShutterVisualState { @@ -129,7 +111,38 @@ private fun animateConversationMediaCaptureShutterVisualState( targetState = shutterPhase, label = "picker_shutter_phase", ) - val innerShutterColor by transition.animateColor( + val surfaceVisualState = transition.animateShutterSurfaceVisualState( + colorScheme = colorScheme, + ) + val recordingStopVisualState = + transition.animateRecordingStopVisualState( + colorScheme = colorScheme, + ) + val videoCenterDotVisualState = + transition.animateVideoCenterDotVisualState( + colorScheme = colorScheme, + ) + val targetVisualState = shutterPhase.toVisualState(colorScheme = colorScheme) + + return ConversationMediaCaptureShutterVisualState( + innerShutterColor = surfaceVisualState.innerShutterColor, + innerShutterSize = surfaceVisualState.innerShutterSize, + outerContainerColor = surfaceVisualState.outerContainerColor, + outerScale = surfaceVisualState.outerScale, + recordingStopAlpha = recordingStopVisualState.alpha, + recordingStopBackgroundColor = targetVisualState.recordingStopBackgroundColor, + recordingStopScale = recordingStopVisualState.scale, + videoCenterDotAlpha = videoCenterDotVisualState.alpha, + videoCenterDotColor = targetVisualState.videoCenterDotColor, + videoCenterDotScale = videoCenterDotVisualState.scale, + ) +} + +@Composable +private fun Transition.animateShutterSurfaceVisualState( + colorScheme: ColorScheme, +): ConversationMediaCaptureShutterSurfaceVisualState { + val innerShutterColor by animateColor( transitionSpec = { PICKER_SHUTTER_COLOR_ANIMATION_SPEC }, @@ -138,7 +151,7 @@ private fun animateConversationMediaCaptureShutterVisualState( phase.toVisualState(colorScheme = colorScheme).innerShutterColor }, ) - val innerShutterSize by transition.animateDp( + val innerShutterSize by animateDp( transitionSpec = { spring( dampingRatio = PICKER_SHUTTER_STATE_TRANSITION_SPRING_DAMPING_RATIO, @@ -150,7 +163,7 @@ private fun animateConversationMediaCaptureShutterVisualState( phase.toVisualState(colorScheme = colorScheme).innerShutterSize }, ) - val outerContainerColor by transition.animateColor( + val outerContainerColor by animateColor( transitionSpec = { PICKER_SHUTTER_COLOR_ANIMATION_SPEC }, @@ -159,7 +172,7 @@ private fun animateConversationMediaCaptureShutterVisualState( phase.toVisualState(colorScheme = colorScheme).outerContainerColor }, ) - val outerScale by transition.animateFloat( + val outerScale by animateFloat( transitionSpec = { PICKER_SHUTTER_FLOAT_SPRING_ANIMATION_SPEC }, @@ -168,7 +181,20 @@ private fun animateConversationMediaCaptureShutterVisualState( phase.toVisualState(colorScheme = colorScheme).outerScale }, ) - val recordingStopAlpha by transition.animateFloat( + + return ConversationMediaCaptureShutterSurfaceVisualState( + innerShutterColor = innerShutterColor, + innerShutterSize = innerShutterSize, + outerContainerColor = outerContainerColor, + outerScale = outerScale, + ) +} + +@Composable +private fun Transition.animateRecordingStopVisualState( + colorScheme: ColorScheme, +): ConversationMediaCaptureRecordingStopVisualState { + val recordingStopAlpha by animateFloat( transitionSpec = { tween(durationMillis = 130) }, @@ -177,7 +203,7 @@ private fun animateConversationMediaCaptureShutterVisualState( phase.toVisualState(colorScheme = colorScheme).recordingStopAlpha }, ) - val recordingStopScale by transition.animateFloat( + val recordingStopScale by animateFloat( transitionSpec = { PICKER_SHUTTER_FLOAT_SPRING_ANIMATION_SPEC }, @@ -186,7 +212,18 @@ private fun animateConversationMediaCaptureShutterVisualState( phase.toVisualState(colorScheme = colorScheme).recordingStopScale }, ) - val videoCenterDotAlpha by transition.animateFloat( + + return ConversationMediaCaptureRecordingStopVisualState( + alpha = recordingStopAlpha, + scale = recordingStopScale, + ) +} + +@Composable +private fun Transition.animateVideoCenterDotVisualState( + colorScheme: ColorScheme, +): ConversationMediaCaptureVideoCenterDotVisualState { + val videoCenterDotAlpha by animateFloat( transitionSpec = { tween(durationMillis = 110) }, @@ -195,7 +232,7 @@ private fun animateConversationMediaCaptureShutterVisualState( phase.toVisualState(colorScheme = colorScheme).videoCenterDotAlpha }, ) - val videoCenterDotScale by transition.animateFloat( + val videoCenterDotScale by animateFloat( transitionSpec = { PICKER_SHUTTER_FLOAT_SPRING_ANIMATION_SPEC }, @@ -204,19 +241,10 @@ private fun animateConversationMediaCaptureShutterVisualState( phase.toVisualState(colorScheme = colorScheme).videoCenterDotScale }, ) - val targetVisualState = shutterPhase.toVisualState(colorScheme = colorScheme) - return ConversationMediaCaptureShutterVisualState( - innerShutterColor = innerShutterColor, - innerShutterSize = innerShutterSize, - outerContainerColor = outerContainerColor, - outerScale = outerScale, - recordingStopAlpha = recordingStopAlpha, - recordingStopBackgroundColor = targetVisualState.recordingStopBackgroundColor, - recordingStopScale = recordingStopScale, - videoCenterDotAlpha = videoCenterDotAlpha, - videoCenterDotColor = targetVisualState.videoCenterDotColor, - videoCenterDotScale = videoCenterDotScale, + return ConversationMediaCaptureVideoCenterDotVisualState( + alpha = videoCenterDotAlpha, + scale = videoCenterDotScale, ) } @@ -347,47 +375,82 @@ private fun resolveConversationMediaCaptureShutterPhase( } } -private fun ConversationMediaCaptureShutterPhase.toVisualState( - colorScheme: ColorScheme, -): ConversationMediaCaptureShutterVisualState { - return when (this) { - Photo -> ConversationMediaCaptureShutterVisualState( - innerShutterColor = colorScheme.inverseOnSurface, - innerShutterSize = PICKER_SHUTTER_PHOTO_INNER_SIZE, - outerContainerColor = colorScheme.scrim.copy(alpha = 0.2f), - outerScale = 1f, - recordingStopAlpha = 0f, - recordingStopBackgroundColor = colorScheme.error.copy(alpha = 0.3f), - recordingStopScale = 0.8f, - videoCenterDotAlpha = 0f, - videoCenterDotColor = colorScheme.inverseOnSurface, - videoCenterDotScale = 0.72f, - ) +@Suppress("ktlint:standard:trailing-comma-on-declaration-site") +private enum class ConversationMediaCaptureShutterPhase { + Photo, + VideoIdle, + VideoRecording; - VideoIdle -> ConversationMediaCaptureShutterVisualState( - innerShutterColor = colorScheme.scrim.copy(alpha = 0.5f), - innerShutterSize = PICKER_SHUTTER_FULL_INNER_SIZE, - outerContainerColor = Color.Transparent, - outerScale = 1f, - recordingStopAlpha = 0f, - recordingStopBackgroundColor = colorScheme.error.copy(alpha = 0.3f), - recordingStopScale = 0.8f, - videoCenterDotAlpha = 1f, - videoCenterDotColor = colorScheme.inverseOnSurface, - videoCenterDotScale = 1f, - ) + fun toVisualState(colorScheme: ColorScheme): ConversationMediaCaptureShutterVisualState { + return when (this) { + Photo -> ConversationMediaCaptureShutterVisualState( + innerShutterColor = colorScheme.inverseOnSurface, + innerShutterSize = PICKER_SHUTTER_PHOTO_INNER_SIZE, + outerContainerColor = colorScheme.scrim.copy(alpha = 0.2f), + outerScale = 1f, + recordingStopAlpha = 0f, + recordingStopBackgroundColor = colorScheme.error.copy(alpha = 0.3f), + recordingStopScale = 0.8f, + videoCenterDotAlpha = 0f, + videoCenterDotColor = colorScheme.inverseOnSurface, + videoCenterDotScale = 0.7f, + ) - VideoRecording -> ConversationMediaCaptureShutterVisualState( - innerShutterColor = colorScheme.errorContainer, - innerShutterSize = PICKER_SHUTTER_FULL_INNER_SIZE, - outerContainerColor = Color.Transparent, - outerScale = 0.97f, - recordingStopAlpha = 1f, - recordingStopBackgroundColor = colorScheme.error.copy(alpha = 0.3f), - recordingStopScale = 1f, - videoCenterDotAlpha = 0f, - videoCenterDotColor = colorScheme.inverseOnSurface, - videoCenterDotScale = 0.72f, - ) + VideoIdle -> ConversationMediaCaptureShutterVisualState( + innerShutterColor = colorScheme.scrim.copy(alpha = 0.5f), + innerShutterSize = PICKER_SHUTTER_FULL_INNER_SIZE, + outerContainerColor = Color.Transparent, + outerScale = 1f, + recordingStopAlpha = 0f, + recordingStopBackgroundColor = colorScheme.error.copy(alpha = 0.3f), + recordingStopScale = 0.8f, + videoCenterDotAlpha = 1f, + videoCenterDotColor = colorScheme.inverseOnSurface, + videoCenterDotScale = 1f, + ) + + VideoRecording -> ConversationMediaCaptureShutterVisualState( + innerShutterColor = colorScheme.errorContainer, + innerShutterSize = PICKER_SHUTTER_FULL_INNER_SIZE, + outerContainerColor = Color.Transparent, + outerScale = 0.97f, + recordingStopAlpha = 1f, + recordingStopBackgroundColor = colorScheme.error.copy(alpha = 0.3f), + recordingStopScale = 1f, + videoCenterDotAlpha = 0f, + videoCenterDotColor = colorScheme.inverseOnSurface, + videoCenterDotScale = 0.7f, + ) + } } } + +private data class ConversationMediaCaptureShutterVisualState( + val innerShutterColor: Color, + val innerShutterSize: Dp, + val outerContainerColor: Color, + val outerScale: Float, + val recordingStopAlpha: Float, + val recordingStopBackgroundColor: Color, + val recordingStopScale: Float, + val videoCenterDotAlpha: Float, + val videoCenterDotColor: Color, + val videoCenterDotScale: Float, +) + +private data class ConversationMediaCaptureShutterSurfaceVisualState( + val innerShutterColor: Color, + val innerShutterSize: Dp, + val outerContainerColor: Color, + val outerScale: Float, +) + +private data class ConversationMediaCaptureRecordingStopVisualState( + val alpha: Float, + val scale: Float, +) + +private data class ConversationMediaCaptureVideoCenterDotVisualState( + val alpha: Float, + val scale: Float, +) diff --git a/src/com/android/messaging/ui/conversation/v2/mediapicker/component/review/ConversationMediaPickerReview.kt b/src/com/android/messaging/ui/conversation/v2/mediapicker/component/review/ConversationMediaPickerReview.kt index b7ccda8e..707c254a 100644 --- a/src/com/android/messaging/ui/conversation/v2/mediapicker/component/review/ConversationMediaPickerReview.kt +++ b/src/com/android/messaging/ui/conversation/v2/mediapicker/component/review/ConversationMediaPickerReview.kt @@ -40,6 +40,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import com.android.messaging.R @@ -93,12 +94,44 @@ internal fun ConversationMediaReviewScene( imeBottomPadding, ) + 12.dp + ConversationMediaReviewSceneContent( + modifier = modifier, + attachments = attachments, + conversationTitle = conversationTitle, + reviewBottomPadding = reviewBottomPadding, + reviewPagerState = reviewPagerState, + isSendActionEnabled = isSendActionEnabled, + onAttachmentPreviewClick = onAttachmentPreviewClick, + onCaptionChange = onCaptionChange, + onAttachmentRemove = onAttachmentRemove, + onAddMoreClick = onAddMoreClick, + onClearReview = onClearReview, + onCloseClick = onCloseClick, + onSendClick = onSendClick, + ) +} + +@Composable +private fun ConversationMediaReviewSceneContent( + modifier: Modifier = Modifier, + attachments: ImmutableList, + conversationTitle: String?, + reviewBottomPadding: Dp, + reviewPagerState: ConversationMediaReviewPagerState, + isSendActionEnabled: Boolean, + onAttachmentPreviewClick: (ComposerAttachmentUiModel.Resolved.VisualMedia) -> Unit, + onCaptionChange: (String, String) -> Unit, + onAttachmentRemove: (String) -> Unit, + onAddMoreClick: () -> Unit, + onClearReview: () -> Unit, + onCloseClick: () -> Unit, + onSendClick: () -> Unit, +) { Box( modifier = modifier, ) { ConversationMediaReviewBackground( - modifier = Modifier - .fillMaxSize(), + modifier = Modifier.fillMaxSize(), pagerState = reviewPagerState.pagerState, attachments = attachments, ) @@ -195,29 +228,9 @@ private fun ConversationMediaReviewPager( BoxWithConstraints( modifier = modifier, ) { - val maxPageWidth = maxWidth * PICKER_REVIEW_PAGE_WIDTH_FRACTION - val maxPageHeight = maxHeight * PICKER_REVIEW_PAGE_MAX_HEIGHT_FRACTION - val pageWidthFromHeight = maxPageHeight * PICKER_REVIEW_PAGE_ASPECT_RATIO - - val pageWidth = when { - maxPageWidth <= pageWidthFromHeight -> maxPageWidth - else -> pageWidthFromHeight - } - - val pageHeight = pageWidth / PICKER_REVIEW_PAGE_ASPECT_RATIO - val pageHorizontalInset = (maxWidth - pageWidth) / 2 - val density = LocalDensity.current - val currentPreviewSize = remember(pageWidth, pageHeight, density) { - with(density) { - IntSize( - width = pageWidth.roundToPx().coerceAtLeast(minimumValue = 1), - height = pageHeight.roundToPx().coerceAtLeast(minimumValue = 1), - ) - } - } - - val previewSize = rememberLargestReviewPreviewSize( - currentPreviewSize = currentPreviewSize, + val pagerLayout = rememberConversationMediaReviewPagerLayout( + maxWidth = maxWidth, + maxHeight = maxHeight, ) HorizontalPager( @@ -225,8 +238,8 @@ private fun ConversationMediaReviewPager( .fillMaxSize() .padding(top = 16.dp), state = pagerState, - contentPadding = PaddingValues(horizontal = pageHorizontalInset), - pageSize = PageSize.Fixed(pageWidth), + contentPadding = PaddingValues(horizontal = pagerLayout.pageHorizontalInset), + pageSize = PageSize.Fixed(pagerLayout.pageWidth), pageSpacing = 12.dp, key = { page -> attachmentContentUris.getOrElse(index = page) { @@ -234,31 +247,93 @@ private fun ConversationMediaReviewPager( } }, ) { page -> - val attachment = attachments.getOrNull(index = page) - - when { - attachment != null -> { - ConversationMediaReviewPageCard( - attachment = attachment, - attachments = attachments, - page = page, - pageHeight = pageHeight, - pageWidth = pageWidth, - pagerState = pagerState, - previewSize = previewSize, - shouldShowDeleteChip = page == visibleDeleteChipPage, - onAttachmentPreviewClick = onAttachmentPreviewClick, - onAttachmentRemove = onAttachmentRemove, - onClearReview = onClearReview, - ) - } + ConversationMediaReviewPageSlot( + attachments = attachments, + page = page, + pageHeight = pagerLayout.pageHeight, + pageWidth = pagerLayout.pageWidth, + pagerState = pagerState, + previewSize = pagerLayout.previewSize, + visibleDeleteChipPage = visibleDeleteChipPage, + onAttachmentPreviewClick = onAttachmentPreviewClick, + onAttachmentRemove = onAttachmentRemove, + onClearReview = onClearReview, + ) + } + } +} - else -> { - Box( - modifier = Modifier.fillMaxSize(), - ) - } - } +@Composable +private fun rememberConversationMediaReviewPagerLayout( + maxWidth: Dp, + maxHeight: Dp, +): ConversationMediaReviewPagerLayout { + val maxPageWidth = maxWidth * PICKER_REVIEW_PAGE_WIDTH_FRACTION + val maxPageHeight = maxHeight * PICKER_REVIEW_PAGE_MAX_HEIGHT_FRACTION + val pageWidthFromHeight = maxPageHeight * PICKER_REVIEW_PAGE_ASPECT_RATIO + + val pageWidth = when { + maxPageWidth <= pageWidthFromHeight -> maxPageWidth + else -> pageWidthFromHeight + } + + val pageHeight = pageWidth / PICKER_REVIEW_PAGE_ASPECT_RATIO + val density = LocalDensity.current + val currentPreviewSize = remember(pageWidth, pageHeight, density) { + with(density) { + IntSize( + width = pageWidth.roundToPx().coerceAtLeast(minimumValue = 1), + height = pageHeight.roundToPx().coerceAtLeast(minimumValue = 1), + ) + } + } + + return ConversationMediaReviewPagerLayout( + pageHeight = pageHeight, + pageHorizontalInset = (maxWidth - pageWidth) / 2, + pageWidth = pageWidth, + previewSize = rememberLargestReviewPreviewSize( + currentPreviewSize = currentPreviewSize, + ), + ) +} + +@Composable +private fun ConversationMediaReviewPageSlot( + attachments: ImmutableList, + page: Int, + pageHeight: Dp, + pageWidth: Dp, + pagerState: PagerState, + previewSize: IntSize, + visibleDeleteChipPage: Int?, + onAttachmentPreviewClick: (ComposerAttachmentUiModel.Resolved.VisualMedia) -> Unit, + onAttachmentRemove: (String) -> Unit, + onClearReview: () -> Unit, +) { + val attachment = attachments.getOrNull(index = page) + + when { + attachment != null -> { + ConversationMediaReviewPageCard( + attachment = attachment, + attachments = attachments, + page = page, + pageHeight = pageHeight, + pageWidth = pageWidth, + pagerState = pagerState, + previewSize = previewSize, + shouldShowDeleteChip = page == visibleDeleteChipPage, + onAttachmentPreviewClick = onAttachmentPreviewClick, + onAttachmentRemove = onAttachmentRemove, + onClearReview = onClearReview, + ) + } + + else -> { + Box( + modifier = Modifier.fillMaxSize(), + ) } } } @@ -291,6 +366,13 @@ private fun rememberLargestReviewPreviewSize( return largestPreviewSize } +private data class ConversationMediaReviewPagerLayout( + val pageHeight: Dp, + val pageHorizontalInset: Dp, + val pageWidth: Dp, + val previewSize: IntSize, +) + @Composable private fun ReviewCaptionTextField( modifier: Modifier = Modifier, diff --git a/src/com/android/messaging/ui/conversation/v2/messages/ui/attachment/ConversationGenericInlineAttachmentRow.kt b/src/com/android/messaging/ui/conversation/v2/messages/ui/attachment/ConversationGenericInlineAttachmentRow.kt index d1edc034..6ecdf1f0 100644 --- a/src/com/android/messaging/ui/conversation/v2/messages/ui/attachment/ConversationGenericInlineAttachmentRow.kt +++ b/src/com/android/messaging/ui/conversation/v2/messages/ui/attachment/ConversationGenericInlineAttachmentRow.kt @@ -62,36 +62,47 @@ internal fun ConversationGenericInlineAttachmentRow( color = MaterialTheme.colorScheme.surfaceContainerHighest, shape = shape, ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 14.dp, vertical = 12.dp), - horizontalArrangement = Arrangement.spacedBy(space = 12.dp), - verticalAlignment = Alignment.CenterVertically, + ConversationGenericInlineAttachmentContent( + title = title, + subtitle = subtitle, + ) + } +} + +@Composable +private fun ConversationGenericInlineAttachmentContent( + title: String, + subtitle: String?, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 14.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.spacedBy(space = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier.size(size = 28.dp), + contentAlignment = Alignment.Center, ) { - Box( - modifier = Modifier.size(size = 28.dp), - contentAlignment = Alignment.Center, - ) { - ConversationFileInlineAttachmentIcon() - } + ConversationFileInlineAttachmentIcon() + } - Column( - verticalArrangement = Arrangement.spacedBy(space = 2.dp), - ) { + Column( + verticalArrangement = Arrangement.spacedBy(space = 2.dp), + ) { + Text( + text = title, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + + subtitle?.let { Text( - text = title, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface, + text = subtitle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, ) - - subtitle?.let { - Text( - text = subtitle, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } } } } diff --git a/src/com/android/messaging/ui/conversation/v2/messages/ui/message/ConversationMessage.kt b/src/com/android/messaging/ui/conversation/v2/messages/ui/message/ConversationMessage.kt index cf36f46b..bd1a0fb1 100644 --- a/src/com/android/messaging/ui/conversation/v2/messages/ui/message/ConversationMessage.kt +++ b/src/com/android/messaging/ui/conversation/v2/messages/ui/message/ConversationMessage.kt @@ -98,10 +98,6 @@ internal enum class ConversationMessageBubbleLayoutMode { private fun rememberConversationMessageLayout( message: ConversationMessageUiModel, ): ConversationMessageLayout { - val context = LocalContext.current - val resources = LocalResources.current - val configuration = LocalConfiguration.current - val bubbleShape = remember( message.canClusterWithPrevious, message.canClusterWithNext, @@ -109,6 +105,52 @@ private fun rememberConversationMessageLayout( messageBubbleShape(message = message) } + val content = rememberConversationMessageContent(message = message) + val metadataText = rememberConversationMessageMetadataText(message = message) + + val showSender = remember( + message.isIncoming, + message.senderDisplayName, + message.canClusterWithPrevious, + ) { + message.isIncoming && + !message.senderDisplayName.isNullOrBlank() && + !message.canClusterWithPrevious + } + + val bubbleLayoutMode = remember( + content, + showSender, + ) { + buildConversationMessageBubbleLayoutMode( + content = content, + showSender = showSender, + ) + } + + return remember( + bubbleShape, + bubbleLayoutMode, + content, + metadataText, + showSender, + ) { + ConversationMessageLayout( + bubbleShape = bubbleShape, + bubbleLayoutMode = bubbleLayoutMode, + content = content, + metadataText = metadataText, + showSender = showSender, + ) + } +} + +@Composable +private fun rememberConversationMessageContent( + message: ConversationMessageUiModel, +): ConversationMessageContent { + val resources = LocalResources.current + val configuration = LocalConfiguration.current val subjectText = remember( resources, configuration, @@ -120,7 +162,7 @@ private fun rememberConversationMessageLayout( ) } - val content = remember( + return remember( message.text, message.mmsSubject, message.parts, @@ -131,13 +173,20 @@ private fun rememberConversationMessageLayout( subjectText = subjectText, ) } +} +@Composable +private fun rememberConversationMessageMetadataText( + message: ConversationMessageUiModel, +): String? { + val context = LocalContext.current + val configuration = LocalConfiguration.current val statusTextResourceId = remember(message.status) { messageStatusTextResourceId(status = message.status) } val statusText = statusTextResourceId?.let { stringResource(it) } - val metadataText = remember( + return remember( context, configuration, message.canClusterWithNext, @@ -151,42 +200,6 @@ private fun rememberConversationMessageLayout( statusText = statusText, ) } - - val showSender = remember( - message.isIncoming, - message.senderDisplayName, - message.canClusterWithPrevious, - ) { - message.isIncoming && - !message.senderDisplayName.isNullOrBlank() && - !message.canClusterWithPrevious - } - - val bubbleLayoutMode = remember( - content, - showSender, - ) { - buildConversationMessageBubbleLayoutMode( - content = content, - showSender = showSender, - ) - } - - return remember( - bubbleShape, - bubbleLayoutMode, - content, - metadataText, - showSender, - ) { - ConversationMessageLayout( - bubbleShape = bubbleShape, - bubbleLayoutMode = bubbleLayoutMode, - content = content, - metadataText = metadataText, - showSender = showSender, - ) - } } private fun messageHorizontalArrangement( @@ -211,31 +224,15 @@ private fun ConversationMessageContent( onMessageLongClick: () -> Unit, onMessageResendClick: () -> Unit, ) { - val hapticFeedback = LocalHapticFeedback.current - val bubbleInteractionModifier = Modifier - .clip(shape = layout.bubbleShape) - .semantics { - selected = isSelected - } - .combinedClickable( - enabled = true, - onClick = { - when { - isSelectionMode -> { - hapticFeedback.performHapticFeedback(HapticFeedbackType.ContextClick) - onMessageClick() - } - - message.canResendMessage -> { - onMessageResendClick() - } - } - }, - onLongClick = { - hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) - onMessageLongClick() - }, - ) + val bubbleInteractionModifier = conversationMessageBubbleInteractionModifier( + message = message, + isSelected = isSelected, + isSelectionMode = isSelectionMode, + layout = layout, + onMessageClick = onMessageClick, + onMessageLongClick = onMessageLongClick, + onMessageResendClick = onMessageResendClick, + ) Column( modifier = Modifier.widthIn(max = maxBubbleWidth), @@ -288,6 +285,43 @@ private fun ConversationMessageContent( } } +@Composable +private fun conversationMessageBubbleInteractionModifier( + message: ConversationMessageUiModel, + isSelected: Boolean, + isSelectionMode: Boolean, + layout: ConversationMessageLayout, + onMessageClick: () -> Unit, + onMessageLongClick: () -> Unit, + onMessageResendClick: () -> Unit, +): Modifier { + val hapticFeedback = LocalHapticFeedback.current + return Modifier + .clip(shape = layout.bubbleShape) + .semantics { + selected = isSelected + } + .combinedClickable( + enabled = true, + onClick = { + when { + isSelectionMode -> { + hapticFeedback.performHapticFeedback(HapticFeedbackType.ContextClick) + onMessageClick() + } + + message.canResendMessage -> { + onMessageResendClick() + } + } + }, + onLongClick = { + hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) + onMessageLongClick() + }, + ) +} + private fun messageContentHorizontalAlignment( message: ConversationMessageUiModel, ): Alignment.Horizontal { diff --git a/src/com/android/messaging/ui/conversation/v2/messages/ui/message/ConversationMessageBubble.kt b/src/com/android/messaging/ui/conversation/v2/messages/ui/message/ConversationMessageBubble.kt index 3ec735fc..4bac5156 100644 --- a/src/com/android/messaging/ui/conversation/v2/messages/ui/message/ConversationMessageBubble.kt +++ b/src/com/android/messaging/ui/conversation/v2/messages/ui/message/ConversationMessageBubble.kt @@ -46,80 +46,140 @@ internal fun ConversationMessageBubble( onExternalUriClick: (String) -> Unit, onMessageLongClick: () -> Unit, ) { + val bubbleModifier = Modifier + .widthIn(max = maxBubbleWidth) + .then(other = modifier) + when (layout.bubbleLayoutMode) { ConversationMessageBubbleLayoutMode.AttachmentOnlyWithoutSurface -> { - ConversationMessageAttachmentOnlyContainer( - modifier = Modifier - .widthIn(max = maxBubbleWidth) - .then(other = modifier), - bubbleShape = layout.bubbleShape, + ConversationMessageAttachmentOnlyBubble( + modifier = bubbleModifier, + layout = layout, message = message, isSelected = isSelected, - ) { - ConversationMessageAttachmentBubbleContent( - modifier = Modifier - .fillMaxWidth(), - content = layout.content, - message = message, - isSelected = isSelected, - isSelectionMode = isSelectionMode, - senderDisplayName = message.senderDisplayName, - showSender = layout.showSender, - onAttachmentClick = onAttachmentClick, - onExternalUriClick = onExternalUriClick, - onMessageLongClick = onMessageLongClick, - ) - } + isSelectionMode = isSelectionMode, + onAttachmentClick = onAttachmentClick, + onExternalUriClick = onExternalUriClick, + onMessageLongClick = onMessageLongClick, + ) } ConversationMessageBubbleLayoutMode.AttachmentsInSurface -> { - ConversationMessageBubbleSurface( - modifier = Modifier - .widthIn(max = maxBubbleWidth) - .then(other = modifier), + ConversationMessageAttachmentSurfaceBubble( + modifier = bubbleModifier, + layout = layout, isSelected = isSelected, message = message, - layout = layout, - ) { - ConversationMessageAttachmentBubbleContent( - content = layout.content, - message = message, - isSelected = isSelected, - isSelectionMode = isSelectionMode, - senderDisplayName = message.senderDisplayName, - showSender = layout.showSender, - onAttachmentClick = onAttachmentClick, - onExternalUriClick = onExternalUriClick, - onMessageLongClick = onMessageLongClick, - ) - } + isSelectionMode = isSelectionMode, + onAttachmentClick = onAttachmentClick, + onExternalUriClick = onExternalUriClick, + onMessageLongClick = onMessageLongClick, + ) } ConversationMessageBubbleLayoutMode.TextInSurface -> { - ConversationMessageBubbleSurface( - modifier = Modifier - .widthIn(max = maxBubbleWidth) - .then(other = modifier), + ConversationMessageTextSurfaceBubble( + modifier = bubbleModifier, + layout = layout, isSelected = isSelected, message = message, - layout = layout, - ) { - ConversationMessageTextBubbleContent( - content = layout.content, - message = message, - isSelected = isSelected, - isSelectionMode = isSelectionMode, - senderDisplayName = message.senderDisplayName, - showSender = layout.showSender, - onAttachmentClick = onAttachmentClick, - onExternalUriClick = onExternalUriClick, - onMessageLongClick = onMessageLongClick, - ) - } + isSelectionMode = isSelectionMode, + onAttachmentClick = onAttachmentClick, + onExternalUriClick = onExternalUriClick, + onMessageLongClick = onMessageLongClick, + ) } } } +@Composable +private fun ConversationMessageAttachmentOnlyBubble( + modifier: Modifier, + layout: ConversationMessageLayout, + message: ConversationMessageUiModel, + isSelected: Boolean, + isSelectionMode: Boolean, + onAttachmentClick: (contentType: String, contentUri: String) -> Unit, + onExternalUriClick: (String) -> Unit, + onMessageLongClick: () -> Unit, +) { + ConversationMessageAttachmentOnlyContainer( + modifier = modifier, + bubbleShape = layout.bubbleShape, + message = message, + isSelected = isSelected, + ) { + ConversationMessageAttachmentBubbleContent( + modifier = Modifier.fillMaxWidth(), + layout = layout, + message = message, + isSelected = isSelected, + isSelectionMode = isSelectionMode, + onAttachmentClick = onAttachmentClick, + onExternalUriClick = onExternalUriClick, + onMessageLongClick = onMessageLongClick, + ) + } +} + +@Composable +private fun ConversationMessageAttachmentSurfaceBubble( + modifier: Modifier, + layout: ConversationMessageLayout, + isSelected: Boolean, + message: ConversationMessageUiModel, + isSelectionMode: Boolean, + onAttachmentClick: (contentType: String, contentUri: String) -> Unit, + onExternalUriClick: (String) -> Unit, + onMessageLongClick: () -> Unit, +) { + ConversationMessageBubbleSurface( + modifier = modifier, + isSelected = isSelected, + message = message, + layout = layout, + ) { + ConversationMessageAttachmentBubbleContent( + layout = layout, + message = message, + isSelected = isSelected, + isSelectionMode = isSelectionMode, + onAttachmentClick = onAttachmentClick, + onExternalUriClick = onExternalUriClick, + onMessageLongClick = onMessageLongClick, + ) + } +} + +@Composable +private fun ConversationMessageTextSurfaceBubble( + modifier: Modifier, + layout: ConversationMessageLayout, + isSelected: Boolean, + message: ConversationMessageUiModel, + isSelectionMode: Boolean, + onAttachmentClick: (contentType: String, contentUri: String) -> Unit, + onExternalUriClick: (String) -> Unit, + onMessageLongClick: () -> Unit, +) { + ConversationMessageBubbleSurface( + modifier = modifier, + isSelected = isSelected, + message = message, + layout = layout, + ) { + ConversationMessageTextBubbleContent( + layout = layout, + message = message, + isSelected = isSelected, + isSelectionMode = isSelectionMode, + onAttachmentClick = onAttachmentClick, + onExternalUriClick = onExternalUriClick, + onMessageLongClick = onMessageLongClick, + ) + } +} + @Composable internal fun ConversationMessageMetadata( message: ConversationMessageUiModel, @@ -136,7 +196,10 @@ internal fun ConversationMessageMetadata( text = metadataText, style = MaterialTheme.typography.labelSmall, color = messageMetadataColor(message = message), - textAlign = messageMetadataTextAlign(message = message), + textAlign = when { + message.isIncoming -> TextAlign.Start + else -> TextAlign.End + }, ) } @@ -204,12 +267,10 @@ private fun ConversationMessageAttachmentOnlyContainer( @Composable private fun ConversationMessageTextBubbleContent( - content: ConversationMessageContent, + layout: ConversationMessageLayout, message: ConversationMessageUiModel, isSelected: Boolean, isSelectionMode: Boolean, - senderDisplayName: String?, - showSender: Boolean, onAttachmentClick: (contentType: String, contentUri: String) -> Unit, onExternalUriClick: (String) -> Unit, onMessageLongClick: () -> Unit, @@ -226,12 +287,12 @@ private fun ConversationMessageTextBubbleContent( message = message, isSelected = isSelected, ), - senderDisplayName = senderDisplayName, - showSender = showSender, + senderDisplayName = message.senderDisplayName, + showSender = layout.showSender, ) ConversationMessageBody( - content = content, + content = layout.content, isIncoming = message.isIncoming, isSelectionMode = isSelectionMode, onAttachmentClick = onAttachmentClick, @@ -244,17 +305,16 @@ private fun ConversationMessageTextBubbleContent( @Composable private fun ConversationMessageAttachmentBubbleContent( modifier: Modifier = Modifier, - content: ConversationMessageContent, + layout: ConversationMessageLayout, message: ConversationMessageUiModel, isSelected: Boolean, isSelectionMode: Boolean, - senderDisplayName: String?, - showSender: Boolean, onAttachmentClick: (contentType: String, contentUri: String) -> Unit, onExternalUriClick: (String) -> Unit, onMessageLongClick: () -> Unit, ) { - val hasHeader = showSender || !content.subjectText.isNullOrBlank() + val content = layout.content + val hasHeader = layout.showSender || !content.subjectText.isNullOrBlank() val hasBodyText = !content.bodyText.isNullOrBlank() Column( @@ -265,17 +325,14 @@ private fun ConversationMessageAttachmentBubbleContent( start = MESSAGE_BUBBLE_MEDIA_TEXT_PADDING, top = MESSAGE_BUBBLE_MEDIA_TEXT_PADDING, end = MESSAGE_BUBBLE_MEDIA_TEXT_PADDING, - bottom = when { - content.subjectText.isNullOrBlank() -> 6.dp - else -> MESSAGE_BUBBLE_MEDIA_SECTION_SPACING - }, + bottom = conversationMessageSenderBottomPadding(content), ), color = messageSenderColor( message = message, isSelected = isSelected, ), - senderDisplayName = senderDisplayName, - showSender = showSender, + senderDisplayName = message.senderDisplayName, + showSender = layout.showSender, ) content.subjectText?.let { subjectText -> @@ -318,6 +375,15 @@ private fun ConversationMessageAttachmentBubbleContent( } } +private fun conversationMessageSenderBottomPadding( + content: ConversationMessageContent, +): Dp { + return when { + content.subjectText.isNullOrBlank() -> 6.dp + else -> MESSAGE_BUBBLE_MEDIA_SECTION_SPACING + } +} + @Composable private fun ConversationMessageBody( content: ConversationMessageContent, @@ -376,13 +442,6 @@ private fun ConversationMessageSender( ) } -private fun messageMetadataTextAlign(message: ConversationMessageUiModel): TextAlign { - return when { - message.isIncoming -> TextAlign.Start - else -> TextAlign.End - } -} - @Composable private fun messageBubbleColor( message: ConversationMessageUiModel, diff --git a/src/com/android/messaging/ui/conversation/v2/metadata/ui/ConversationTopAppBar.kt b/src/com/android/messaging/ui/conversation/v2/metadata/ui/ConversationTopAppBar.kt index dfa8391b..cbc11ed3 100644 --- a/src/com/android/messaging/ui/conversation/v2/metadata/ui/ConversationTopAppBar.kt +++ b/src/com/android/messaging/ui/conversation/v2/metadata/ui/ConversationTopAppBar.kt @@ -30,7 +30,6 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar -import androidx.compose.material3.TopAppBarColors import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable @@ -99,10 +98,23 @@ internal fun ConversationTopAppBar( metadata = metadata, ) val isTitleClickable = metadata is ConversationMetadataUiState.Present + val overflowVisibility = ConversationTopAppBarOverflowVisibility( + isAddPeopleVisible = isAddPeopleVisible, + isArchiveVisible = isArchiveVisible, + isUnarchiveVisible = isUnarchiveVisible, + isAddContactVisible = isAddContactVisible, + isDeleteConversationVisible = isDeleteConversationVisible, + isSimSelectorVisible = simSelector.isAvailable, + ) TopAppBar( modifier = modifier.fillMaxWidth(), - colors = conversationTopAppBarColors(), + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + navigationIconContentColor = MaterialTheme.colorScheme.onSurface, + titleContentColor = MaterialTheme.colorScheme.onSurface, + actionIconContentColor = MaterialTheme.colorScheme.onSurfaceVariant, + ), title = { ConversationTopAppBarTitle( isClickable = isTitleClickable, @@ -121,60 +133,25 @@ internal fun ConversationTopAppBar( } }, actions = { - if (isCallVisible) { - IconButton( - modifier = Modifier.testTag(CONVERSATION_CALL_BUTTON_TEST_TAG), - onClick = onCallClick, - ) { - Icon( - imageVector = Icons.Rounded.Call, - contentDescription = stringResource(id = R.string.action_call), - ) - } - } - val isSimSelectorVisible = simSelector.isAvailable - - val isOverflowVisible = isAddPeopleVisible || - isArchiveVisible || - isUnarchiveVisible || - isAddContactVisible || - isDeleteConversationVisible || - isSimSelectorVisible - - if (isOverflowVisible) { - ConversationTopAppBarOverflowMenu( - isAddPeopleVisible = isAddPeopleVisible, - isArchiveVisible = isArchiveVisible, - isUnarchiveVisible = isUnarchiveVisible, - isAddContactVisible = isAddContactVisible, - isDeleteConversationVisible = isDeleteConversationVisible, - isSimSelectorVisible = isSimSelectorVisible, - simSelectorLabel = simSelector.selectedSubscription - ?.label - ?.resolveDisplayName() - .orEmpty(), - onAddPeopleClick = onAddPeopleClick, - onArchiveClick = onArchiveClick, - onUnarchiveClick = onUnarchiveClick, - onAddContactClick = onAddContactClick, - onDeleteConversationClick = onDeleteConversationClick, - onSimSelectorClick = onSimSelectorClick, - ) - } + ConversationTopAppBarActions( + isCallVisible = isCallVisible, + overflowVisibility = overflowVisibility, + simSelectorLabel = simSelector.selectedSubscription + ?.label + ?.resolveDisplayName() + .orEmpty(), + onCallClick = onCallClick, + onAddPeopleClick = onAddPeopleClick, + onArchiveClick = onArchiveClick, + onUnarchiveClick = onUnarchiveClick, + onAddContactClick = onAddContactClick, + onDeleteConversationClick = onDeleteConversationClick, + onSimSelectorClick = onSimSelectorClick, + ) }, ) } -@Composable -private fun conversationTopAppBarColors(): TopAppBarColors { - return TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.surfaceContainer, - navigationIconContentColor = MaterialTheme.colorScheme.onSurface, - titleContentColor = MaterialTheme.colorScheme.onSurface, - actionIconContentColor = MaterialTheme.colorScheme.onSurfaceVariant, - ) -} - @Composable private fun rememberConversationTopAppBarPresentation( metadata: ConversationMetadataUiState, @@ -262,14 +239,48 @@ private fun ConversationTopAppBarText( } } +@Composable +private fun ConversationTopAppBarActions( + isCallVisible: Boolean, + overflowVisibility: ConversationTopAppBarOverflowVisibility, + simSelectorLabel: String, + onCallClick: () -> Unit, + onAddPeopleClick: () -> Unit, + onArchiveClick: () -> Unit, + onUnarchiveClick: () -> Unit, + onAddContactClick: () -> Unit, + onDeleteConversationClick: () -> Unit, + onSimSelectorClick: () -> Unit, +) { + if (isCallVisible) { + IconButton( + modifier = Modifier.testTag(CONVERSATION_CALL_BUTTON_TEST_TAG), + onClick = onCallClick, + ) { + Icon( + imageVector = Icons.Rounded.Call, + contentDescription = stringResource(id = R.string.action_call), + ) + } + } + + if (overflowVisibility.isOverflowVisible) { + ConversationTopAppBarOverflowMenu( + visibility = overflowVisibility, + simSelectorLabel = simSelectorLabel, + onAddPeopleClick = onAddPeopleClick, + onArchiveClick = onArchiveClick, + onUnarchiveClick = onUnarchiveClick, + onAddContactClick = onAddContactClick, + onDeleteConversationClick = onDeleteConversationClick, + onSimSelectorClick = onSimSelectorClick, + ) + } +} + @Composable private fun ConversationTopAppBarOverflowMenu( - isAddPeopleVisible: Boolean, - isArchiveVisible: Boolean, - isUnarchiveVisible: Boolean, - isAddContactVisible: Boolean, - isDeleteConversationVisible: Boolean, - isSimSelectorVisible: Boolean, + visibility: ConversationTopAppBarOverflowVisibility, simSelectorLabel: String, onAddPeopleClick: () -> Unit, onArchiveClick: () -> Unit, @@ -292,65 +303,84 @@ private fun ConversationTopAppBarOverflowMenu( DropdownMenu( expanded = isExpanded, - onDismissRequest = { - @Suppress("AssignedValueIsNeverRead") - isExpanded = false - }, + onDismissRequest = { isExpanded = false }, ) { - val dismissAndInvoke: (() -> Unit) -> Unit = { action -> - @Suppress("AssignedValueIsNeverRead") - isExpanded = false - action() - } - - ConversationTopAppBarOverflowMenuItem( - isVisible = isSimSelectorVisible, - testTag = CONVERSATION_SIM_SELECTOR_MENU_ITEM_TEST_TAG, - label = simSelectorLabel, - icon = Icons.Rounded.SimCard, - onClick = { dismissAndInvoke(onSimSelectorClick) }, + ConversationTopAppBarOverflowMenuContent( + visibility = visibility, + simSelectorLabel = simSelectorLabel, + onAddPeopleClick = onAddPeopleClick, + onArchiveClick = onArchiveClick, + onUnarchiveClick = onUnarchiveClick, + onAddContactClick = onAddContactClick, + onDeleteConversationClick = onDeleteConversationClick, + onSimSelectorClick = onSimSelectorClick, + onItemClick = { action -> + isExpanded = false + action() + }, ) + } +} - ConversationTopAppBarOverflowMenuItem( - isVisible = isAddPeopleVisible, - testTag = CONVERSATION_ADD_PEOPLE_BUTTON_TEST_TAG, - label = stringResource(id = R.string.conversation_add_people), - icon = Icons.Rounded.GroupAdd, - onClick = { dismissAndInvoke(onAddPeopleClick) }, - ) +@Composable +private fun ConversationTopAppBarOverflowMenuContent( + visibility: ConversationTopAppBarOverflowVisibility, + simSelectorLabel: String, + onAddPeopleClick: () -> Unit, + onArchiveClick: () -> Unit, + onUnarchiveClick: () -> Unit, + onAddContactClick: () -> Unit, + onDeleteConversationClick: () -> Unit, + onSimSelectorClick: () -> Unit, + onItemClick: (() -> Unit) -> Unit, +) { + ConversationTopAppBarOverflowMenuItem( + isVisible = visibility.isSimSelectorVisible, + testTag = CONVERSATION_SIM_SELECTOR_MENU_ITEM_TEST_TAG, + label = simSelectorLabel, + icon = Icons.Rounded.SimCard, + onClick = { onItemClick(onSimSelectorClick) }, + ) - ConversationTopAppBarOverflowMenuItem( - isVisible = isAddContactVisible, - testTag = CONVERSATION_ADD_CONTACT_BUTTON_TEST_TAG, - label = stringResource(id = R.string.action_add_contact), - icon = Icons.Rounded.PersonAdd, - onClick = { dismissAndInvoke(onAddContactClick) }, - ) + ConversationTopAppBarOverflowMenuItem( + isVisible = visibility.isAddPeopleVisible, + testTag = CONVERSATION_ADD_PEOPLE_BUTTON_TEST_TAG, + label = stringResource(id = R.string.conversation_add_people), + icon = Icons.Rounded.GroupAdd, + onClick = { onItemClick(onAddPeopleClick) }, + ) - ConversationTopAppBarOverflowMenuItem( - isVisible = isArchiveVisible, - testTag = CONVERSATION_ARCHIVE_BUTTON_TEST_TAG, - label = stringResource(id = R.string.action_archive), - icon = Icons.Rounded.Archive, - onClick = { dismissAndInvoke(onArchiveClick) }, - ) + ConversationTopAppBarOverflowMenuItem( + isVisible = visibility.isAddContactVisible, + testTag = CONVERSATION_ADD_CONTACT_BUTTON_TEST_TAG, + label = stringResource(id = R.string.action_add_contact), + icon = Icons.Rounded.PersonAdd, + onClick = { onItemClick(onAddContactClick) }, + ) - ConversationTopAppBarOverflowMenuItem( - isVisible = isUnarchiveVisible, - testTag = CONVERSATION_UNARCHIVE_BUTTON_TEST_TAG, - label = stringResource(id = R.string.action_unarchive), - icon = Icons.Rounded.Unarchive, - onClick = { dismissAndInvoke(onUnarchiveClick) }, - ) + ConversationTopAppBarOverflowMenuItem( + isVisible = visibility.isArchiveVisible, + testTag = CONVERSATION_ARCHIVE_BUTTON_TEST_TAG, + label = stringResource(id = R.string.action_archive), + icon = Icons.Rounded.Archive, + onClick = { onItemClick(onArchiveClick) }, + ) - ConversationTopAppBarOverflowMenuItem( - isVisible = isDeleteConversationVisible, - testTag = CONVERSATION_DELETE_CONVERSATION_BUTTON_TEST_TAG, - label = stringResource(id = R.string.action_delete), - icon = Icons.Rounded.Delete, - onClick = { dismissAndInvoke(onDeleteConversationClick) }, - ) - } + ConversationTopAppBarOverflowMenuItem( + isVisible = visibility.isUnarchiveVisible, + testTag = CONVERSATION_UNARCHIVE_BUTTON_TEST_TAG, + label = stringResource(id = R.string.action_unarchive), + icon = Icons.Rounded.Unarchive, + onClick = { onItemClick(onUnarchiveClick) }, + ) + + ConversationTopAppBarOverflowMenuItem( + isVisible = visibility.isDeleteConversationVisible, + testTag = CONVERSATION_DELETE_CONVERSATION_BUTTON_TEST_TAG, + label = stringResource(id = R.string.action_delete), + icon = Icons.Rounded.Delete, + onClick = { onItemClick(onDeleteConversationClick) }, + ) } @Composable @@ -550,3 +580,23 @@ private data class ConversationTopAppBarPresentation( val subtitleContentDescription: String?, val avatar: ConversationMetadataUiState.Avatar, ) + +@Immutable +private data class ConversationTopAppBarOverflowVisibility( + val isAddPeopleVisible: Boolean, + val isArchiveVisible: Boolean, + val isUnarchiveVisible: Boolean, + val isAddContactVisible: Boolean, + val isDeleteConversationVisible: Boolean, + val isSimSelectorVisible: Boolean, +) { + val isOverflowVisible: Boolean + get() { + return isAddPeopleVisible || + isArchiveVisible || + isUnarchiveVisible || + isAddContactVisible || + isDeleteConversationVisible || + isSimSelectorVisible + } +} diff --git a/src/com/android/messaging/ui/conversation/v2/navigation/ConversationNavGraph.kt b/src/com/android/messaging/ui/conversation/v2/navigation/ConversationNavGraph.kt index d6064f94..f5383807 100644 --- a/src/com/android/messaging/ui/conversation/v2/navigation/ConversationNavGraph.kt +++ b/src/com/android/messaging/ui/conversation/v2/navigation/ConversationNavGraph.kt @@ -1,7 +1,9 @@ package com.android.messaging.ui.conversation.v2.navigation import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.State import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState @@ -9,6 +11,7 @@ import androidx.compose.ui.Modifier import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator +import androidx.navigation3.runtime.NavEntry import androidx.navigation3.runtime.NavKey import androidx.navigation3.runtime.entryProvider import androidx.navigation3.runtime.rememberNavBackStack @@ -38,153 +41,34 @@ internal fun ConversationNavGraph( ) { val entryUiState by entryModel.uiState.collectAsStateWithLifecycle() val backStack = rememberNavBackStack(initialNavKey(launchRequest)) - val latestEntryModel = rememberUpdatedState(entryModel) - val latestEntryUiState = rememberUpdatedState(entryUiState) - val latestNavigationReducer = rememberUpdatedState(navigationReducer) - val latestOnConversationDetailsClick = rememberUpdatedState(onConversationDetailsClick) - val latestOnFinish = rememberUpdatedState(onFinish) - val latestIsLaunchedFromBubble = rememberUpdatedState( - launchRequest?.isLaunchedFromBubble == true, + val routeState = ConversationNavRouteState( + backStack = backStack, + entryModel = rememberUpdatedState(newValue = entryModel), + entryUiState = rememberUpdatedState(newValue = entryUiState), + isLaunchedFromBubble = rememberUpdatedState( + newValue = launchRequest?.isLaunchedFromBubble == true, + ), + navigationReducer = rememberUpdatedState(newValue = navigationReducer), + onConversationDetailsClick = rememberUpdatedState( + newValue = onConversationDetailsClick, + ), + onFinish = rememberUpdatedState(newValue = onFinish), ) - val entryDecorators = listOf( rememberSaveableStateHolderNavEntryDecorator(), rememberViewModelStoreNavEntryDecorator(), ) - - val entryProvider = remember( - backStack, - ) { - entryProvider { - entry { navKey -> - val currentEntryUiState = latestEntryUiState.value - val currentEntryModel = latestEntryModel.value - val currentOnFinish = latestOnFinish.value - - ConversationScreen( - conversationId = navKey.conversationId, - launchGeneration = currentEntryUiState.launchGeneration, - cancelIncomingNotification = !latestIsLaunchedFromBubble.value, - onAddPeopleClick = { - latestNavigationReducer.value.navigateToAddParticipants( - backStack = backStack, - conversationId = navKey.conversationId, - ) - }, - onConversationDetailsClick = { - latestOnConversationDetailsClick.value(navKey.conversationId) - }, - onNavigateBack = { - popBackStackOrFinish( - backStack = backStack, - navigationReducer = latestNavigationReducer.value, - onFinish = currentOnFinish, - ) - }, - pendingDraft = pendingDraftForConversation( - entryUiState = currentEntryUiState, - conversationId = navKey.conversationId, - ), - pendingScrollPosition = pendingScrollPositionForConversation( - entryUiState = currentEntryUiState, - conversationId = navKey.conversationId, - ), - pendingStartupAttachment = pendingStartupAttachmentForConversation( - entryUiState = currentEntryUiState, - conversationId = navKey.conversationId, - ), - onPendingDraftConsumed = { - currentEntryModel.onDraftPayloadConsumed( - conversationId = navKey.conversationId, - ) - }, - onPendingScrollPositionConsumed = { - currentEntryModel.onScrollPositionConsumed( - conversationId = navKey.conversationId, - ) - }, - onPendingStartupAttachmentConsumed = { - currentEntryModel.onStartupAttachmentConsumed( - conversationId = navKey.conversationId, - ) - }, - ) - } - - entry { - val currentEntryUiState = latestEntryUiState.value - val currentEntryModel = latestEntryModel.value - - NewChatScreen( - isCreatingGroup = currentEntryUiState.isCreatingGroup, - isResolvingConversation = currentEntryUiState.isResolvingConversation, - isResolvingConversationIndicatorVisible = currentEntryUiState - .isResolvingConversationIndicatorVisible, - onContactClick = currentEntryModel::onNewChatRecipientSelected, - onContactLongClick = currentEntryModel::onNewChatRecipientLongPressed, - onCreateGroupClick = currentEntryModel::onCreateGroupRequested, - onCreateGroupConfirmed = currentEntryModel::onCreateGroupConfirmed, - onCreateGroupRecipientClick = currentEntryModel::onCreateGroupRecipientClicked, - onNavigateBack = { - handleNewChatBack( - entryModel = currentEntryModel, - entryUiState = currentEntryUiState, - backStack = backStack, - navigationReducer = latestNavigationReducer.value, - onFinish = latestOnFinish.value, - ) - }, - resolvingRecipientDestination = currentEntryUiState - .resolvingRecipientDestination, - selectedGroupRecipientDestinations = currentEntryUiState - .selectedGroupRecipientDestinations, - ) - } - - entry { navKey -> - AddParticipantsScreen( - conversationId = navKey.conversationId, - onNavigateBack = { - popBackStackOrFinish( - backStack = backStack, - navigationReducer = latestNavigationReducer.value, - onFinish = latestOnFinish.value, - ) - }, - onNavigateToConversation = { resolvedConversationId -> - latestNavigationReducer.value.replaceCurrentConversation( - backStack = backStack, - conversationId = resolvedConversationId, - ) - }, - ) - } - - entry { navKey -> - RecipientPickerScreen(mode = navKey.mode) - } - } + val entryProvider = remember(backStack) { + conversationNavEntryProvider(routeState = routeState) } - - LaunchedEffect(launchRequest) { - launchRequest?.let(entryModel::onLaunchRequest) - updateBackStackForLaunch( - backStack = backStack, - launchRequest = launchRequest, - navigationReducer = latestNavigationReducer.value, - ) + val effectState = remember(backStack, entryModel) { + conversationNavEffectState(routeState = routeState, entryModel = entryModel) } - LaunchedEffect(entryModel, onFinish) { - entryModel.effects.collect { effect -> - handleEntryEffect( - backStack = backStack, - effect = effect, - navigationReducer = latestNavigationReducer.value, - onFinish = onFinish, - ) - } - } + ConversationNavGraphEffects( + launchRequest = launchRequest, + effectState = effectState, + ) NavDisplay( backStack = backStack, @@ -192,10 +76,10 @@ internal fun ConversationNavGraph( onBack = { handleNavBack( backStack = backStack, - entryModel = latestEntryModel.value, - entryUiState = latestEntryUiState.value, - navigationReducer = latestNavigationReducer.value, - onFinish = latestOnFinish.value, + entryModel = entryModel, + entryUiState = entryUiState, + navigationReducer = navigationReducer, + onFinish = onFinish, ) }, entryDecorators = entryDecorators, @@ -203,6 +87,148 @@ internal fun ConversationNavGraph( ) } +private fun conversationNavEntryProvider( + routeState: ConversationNavRouteState, +): (NavKey) -> NavEntry { + return entryProvider { + entry( + content = conversationScreenRouteContent(routeState = routeState), + ) + entry( + content = newChatRouteContent(routeState = routeState), + ) + entry( + content = addParticipantsRouteContent(routeState = routeState), + ) + entry { navKey -> + RecipientPickerScreen(mode = navKey.mode) + } + } +} + +private fun conversationScreenRouteContent( + routeState: ConversationNavRouteState, +): @Composable (ConversationNavKey) -> Unit { + return { navKey -> + val conversationId = navKey.conversationId + val entryModel = routeState.entryModel.value + val entryUiState = routeState.entryUiState.value + val navigationReducer = routeState.navigationReducer.value + val pendingPayload = pendingLaunchPayloadForConversation( + entryUiState = entryUiState, + conversationId = conversationId, + ) + + ConversationScreen( + conversationId = conversationId, + launchGeneration = entryUiState.launchGeneration, + cancelIncomingNotification = !routeState.isLaunchedFromBubble.value, + onAddPeopleClick = { + navigationReducer.navigateToAddParticipants( + backStack = routeState.backStack, + conversationId = conversationId, + ) + }, + onConversationDetailsClick = { + routeState.onConversationDetailsClick.value(conversationId) + }, + onNavigateBack = { + popBackStackOrFinish( + backStack = routeState.backStack, + navigationReducer = navigationReducer, + onFinish = routeState.onFinish.value, + ) + }, + pendingDraft = pendingPayload.draft, + pendingScrollPosition = pendingPayload.scrollPosition, + pendingStartupAttachment = pendingPayload.startupAttachment, + onPendingDraftConsumed = { + entryModel.onDraftPayloadConsumed(conversationId = conversationId) + }, + onPendingScrollPositionConsumed = { + entryModel.onScrollPositionConsumed(conversationId = conversationId) + }, + onPendingStartupAttachmentConsumed = { + entryModel.onStartupAttachmentConsumed(conversationId = conversationId) + }, + ) + } +} + +private fun newChatRouteContent( + routeState: ConversationNavRouteState, +): @Composable (NewChatNavKey) -> Unit { + return { + val entryModel = routeState.entryModel.value + val entryUiState = routeState.entryUiState.value + + NewChatScreen( + isCreatingGroup = entryUiState.isCreatingGroup, + isResolvingConversation = entryUiState.isResolvingConversation, + isResolvingConversationIndicatorVisible = entryUiState + .isResolvingConversationIndicatorVisible, + onContactClick = entryModel::onNewChatRecipientSelected, + onContactLongClick = entryModel::onNewChatRecipientLongPressed, + onCreateGroupClick = entryModel::onCreateGroupRequested, + onCreateGroupConfirmed = entryModel::onCreateGroupConfirmed, + onCreateGroupRecipientClick = entryModel::onCreateGroupRecipientClicked, + onNavigateBack = { + handleNewChatBack( + entryModel = entryModel, + entryUiState = entryUiState, + backStack = routeState.backStack, + navigationReducer = routeState.navigationReducer.value, + onFinish = routeState.onFinish.value, + ) + }, + resolvingRecipientDestination = entryUiState.resolvingRecipientDestination, + selectedGroupRecipientDestinations = entryUiState.selectedGroupRecipientDestinations, + ) + } +} + +private fun addParticipantsRouteContent( + routeState: ConversationNavRouteState, +): @Composable (AddParticipantsNavKey) -> Unit { + return { navKey -> + AddParticipantsScreen( + conversationId = navKey.conversationId, + onNavigateBack = { + popBackStackOrFinish( + backStack = routeState.backStack, + navigationReducer = routeState.navigationReducer.value, + onFinish = routeState.onFinish.value, + ) + }, + onNavigateToConversation = { resolvedConversationId -> + routeState.navigationReducer.value.replaceCurrentConversation( + backStack = routeState.backStack, + conversationId = resolvedConversationId, + ) + }, + ) + } +} + +@Composable +private fun ConversationNavGraphEffects( + launchRequest: ConversationEntryLaunchRequest?, + effectState: ConversationNavEffectState, +) { + val latestEffectState = rememberUpdatedState(newValue = effectState) + + LaunchedEffect(launchRequest) { + launchRequest?.let(latestEffectState.value.onLaunchRequest) + latestEffectState.value.onLaunchBackStackUpdate(launchRequest) + } + + LaunchedEffect(effectState.collectEntryEffects) { + effectState.collectEntryEffects { effect -> + latestEffectState.value.onEntryEffect(effect) + } + } +} + private fun initialNavKey(launchRequest: ConversationEntryLaunchRequest?): NavKey { return launchRequest ?.conversationId @@ -210,18 +236,6 @@ private fun initialNavKey(launchRequest: ConversationEntryLaunchRequest?): NavKe ?: NewChatNavKey } -private fun pendingDraftForConversation( - entryUiState: ConversationEntryUiState, - conversationId: String, -): ConversationDraft? { - return when { - entryUiState.conversationId == conversationId -> { - entryUiState.pendingDraft - } - else -> null - } -} - private fun updateBackStackForLaunch( backStack: MutableList, launchRequest: ConversationEntryLaunchRequest?, @@ -284,26 +298,75 @@ private fun handleNewChatBack( ) } -private fun pendingScrollPositionForConversation( +private fun pendingLaunchPayloadForConversation( entryUiState: ConversationEntryUiState, conversationId: String, -): Int? { - return when { - entryUiState.conversationId == conversationId -> entryUiState.pendingScrollPosition - else -> null +): ConversationPendingLaunchPayload { + if (entryUiState.conversationId != conversationId) { + return ConversationPendingLaunchPayload() } + + return ConversationPendingLaunchPayload( + draft = entryUiState.pendingDraft, + scrollPosition = entryUiState.pendingScrollPosition, + startupAttachment = entryUiState.pendingStartupAttachment, + ) } -private fun pendingStartupAttachmentForConversation( - entryUiState: ConversationEntryUiState, - conversationId: String, -): ConversationEntryStartupAttachment? { - return when { - entryUiState.conversationId == conversationId -> { - entryUiState.pendingStartupAttachment - } - else -> null - } +private class ConversationNavRouteState( + val backStack: MutableList, + val entryModel: State, + val entryUiState: State, + val isLaunchedFromBubble: State, + val navigationReducer: State, + val onConversationDetailsClick: State<(String) -> Unit>, + val onFinish: State<() -> Unit>, +) + +private typealias ConversationEntryEffectCollector = + suspend ((ConversationEntryEffect) -> Unit) -> Unit + +@Immutable +private data class ConversationNavEffectState( + val onLaunchRequest: (ConversationEntryLaunchRequest) -> Unit, + val onLaunchBackStackUpdate: (ConversationEntryLaunchRequest?) -> Unit, + val collectEntryEffects: ConversationEntryEffectCollector, + val onEntryEffect: (ConversationEntryEffect) -> Unit, +) + +private data class ConversationPendingLaunchPayload( + val draft: ConversationDraft? = null, + val scrollPosition: Int? = null, + val startupAttachment: ConversationEntryStartupAttachment? = null, +) + +private fun conversationNavEffectState( + routeState: ConversationNavRouteState, + entryModel: ConversationEntryModel, +): ConversationNavEffectState { + return ConversationNavEffectState( + onLaunchRequest = entryModel::onLaunchRequest, + onLaunchBackStackUpdate = { launchRequest -> + updateBackStackForLaunch( + backStack = routeState.backStack, + launchRequest = launchRequest, + navigationReducer = routeState.navigationReducer.value, + ) + }, + collectEntryEffects = { onEffect -> + entryModel.effects.collect { effect -> + onEffect(effect) + } + }, + onEntryEffect = { effect -> + handleEntryEffect( + backStack = routeState.backStack, + effect = effect, + navigationReducer = routeState.navigationReducer.value, + onFinish = routeState.onFinish.value, + ) + }, + ) } private fun handleEntryEffect( diff --git a/src/com/android/messaging/ui/conversation/v2/screen/ConversationScreen.kt b/src/com/android/messaging/ui/conversation/v2/screen/ConversationScreen.kt index f8a3df05..ebca1f2e 100644 --- a/src/com/android/messaging/ui/conversation/v2/screen/ConversationScreen.kt +++ b/src/com/android/messaging/ui/conversation/v2/screen/ConversationScreen.kt @@ -1,9 +1,5 @@ package com.android.messaging.ui.conversation.v2.screen -import android.Manifest -import androidx.activity.compose.BackHandler -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize @@ -30,26 +26,17 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.geometry.Rect as ComposeRect -import androidx.compose.ui.layout.boundsInWindow -import androidx.compose.ui.layout.onGloballyPositioned -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.compose.LifecycleEventEffect import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.android.messaging.R import com.android.messaging.data.conversation.model.draft.ConversationDraft import com.android.messaging.ui.conversation.v2.CONVERSATION_LOADING_INDICATOR_TEST_TAG -import com.android.messaging.ui.conversation.v2.audio.model.ConversationAudioRecordingPhase -import com.android.messaging.ui.conversation.v2.composer.model.ComposerAttachmentUiModel import com.android.messaging.ui.conversation.v2.composer.ui.ConversationComposerSection import com.android.messaging.ui.conversation.v2.composer.ui.ConversationSimSelectorSheet import com.android.messaging.ui.conversation.v2.entry.model.ConversationEntryStartupAttachment -import com.android.messaging.ui.conversation.v2.mediapicker.ConversationMediaPickerOverlay -import com.android.messaging.ui.conversation.v2.mediapicker.RefreshConversationMediaPickerPermissionsEffect import com.android.messaging.ui.conversation.v2.mediapicker.rememberConversationMediaPickerPermissionState import com.android.messaging.ui.conversation.v2.mediapicker.rememberConversationMediaPickerState import com.android.messaging.ui.conversation.v2.messages.model.message.ConversationMessageUiModel @@ -57,18 +44,11 @@ import com.android.messaging.ui.conversation.v2.messages.model.message.Conversat import com.android.messaging.ui.conversation.v2.messages.ui.ConversationMessages import com.android.messaging.ui.conversation.v2.metadata.ui.ConversationTopAppBar import com.android.messaging.ui.conversation.v2.screen.model.ConversationMessageDeleteConfirmationUiState -import com.android.messaging.ui.conversation.v2.screen.model.ConversationMessageSelectionAction import com.android.messaging.ui.conversation.v2.screen.model.ConversationScreenScaffoldUiState import kotlinx.collections.immutable.ImmutableList private const val SMOOTH_SCROLL_JUMP_THRESHOLD = 15 -private enum class PendingAudioRecordingStartMode { - None, - Unlocked, - Locked, -} - @Composable internal fun ConversationScreen( modifier: Modifier = Modifier, @@ -86,232 +66,68 @@ internal fun ConversationScreen( onPendingStartupAttachmentConsumed: () -> Unit = {}, screenModel: ConversationScreenModel = hiltViewModel(), ) { - val messageFieldFocusRequester = remember { - FocusRequester() - } + val messageFieldFocusRequester = remember { FocusRequester() } val mediaPickerState = rememberConversationMediaPickerState() val scaffoldUiState by screenModel.scaffoldUiState.collectAsStateWithLifecycle() val mediaPickerOverlayUiState by screenModel .mediaPickerOverlayUiState .collectAsStateWithLifecycle() - val context = LocalContext.current - val permissionState = rememberConversationMediaPickerPermissionState(context = context) - - val hostBoundsState = remember { - mutableStateOf(value = null) - } - val snackbarHostState = remember { - SnackbarHostState() - } - - var pendingAudioRecordingStartMode by rememberSaveable { - mutableStateOf(value = PendingAudioRecordingStartMode.None) - } - - val contactPickerLauncher = rememberLauncherForActivityResult( - contract = ActivityResultContracts.PickContact(), - ) { contactUri -> - screenModel.onContactCardPicked(contactUri = contactUri?.toString()) - } - - val audioPermissionLauncher = rememberLauncherForActivityResult( - contract = ActivityResultContracts.RequestPermission(), - ) { isGranted -> - permissionState.audioPermissionGranted = isGranted - - val startMode = pendingAudioRecordingStartMode - pendingAudioRecordingStartMode = PendingAudioRecordingStartMode.None - - if (!isGranted) { - return@rememberLauncherForActivityResult - } - - startAudioRecording( - screenModel = screenModel, - startMode = startMode, - ) - } - - val requestAudioRecordingStart = { startMode: PendingAudioRecordingStartMode -> - if (permissionState.audioPermissionGranted) { - startAudioRecording( - screenModel = screenModel, - startMode = startMode, - ) - } else { - pendingAudioRecordingStartMode = startMode - audioPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO) - } - } - - LaunchedEffect(conversationId) { - screenModel.onConversationIdChanged(conversationId = conversationId) - } + val permissionState = rememberConversationMediaPickerPermissionState() - LaunchedEffect( - conversationId, - launchGeneration, - pendingDraft, - ) { - if ( - conversationId != null && - launchGeneration != null && - pendingDraft != null - ) { - screenModel.onSeedDraft( - conversationId = conversationId, - draft = pendingDraft, - ) - onPendingDraftConsumed() - } - } - - LaunchedEffect( - conversationId, - launchGeneration, - pendingStartupAttachment, - ) { - if ( - conversationId != null && - launchGeneration != null && - pendingStartupAttachment != null - ) { - screenModel.onOpenStartupAttachment( - conversationId = conversationId, - startupAttachment = pendingStartupAttachment, - ) - onPendingStartupAttachmentConsumed() - } - } - - RefreshConversationMediaPickerPermissionsEffect( - context = context, + val hostBoundsState = remember { mutableStateOf(value = null) } + val snackbarHostState = remember { SnackbarHostState() } + val onOpenContactPicker = rememberOpenContactPickerCallback(screenModel = screenModel) + val requestAudioRecordingStart = rememberAudioRecordingStartRequest( + screenModel = screenModel, permissionState = permissionState, ) - LifecycleEventEffect(event = Lifecycle.Event.ON_RESUME) { - screenModel.onScreenForegrounded(cancelNotification = cancelIncomingNotification) - } - - LifecycleEventEffect(event = Lifecycle.Event.ON_PAUSE) { - screenModel.onScreenBackgrounded() - } - - LifecycleEventEffect(event = Lifecycle.Event.ON_STOP) { - val isRecording = scaffoldUiState.composer.audioRecording.phase == - ConversationAudioRecordingPhase.Recording - - if (isRecording) { - screenModel.onAudioRecordingCancel() - } - screenModel.persistDraft() - } - - BackHandler(enabled = scaffoldUiState.selection.isSelectionMode) { - screenModel.dismissMessageSelection() - } - - ConversationScreenEffects( - screenModel = screenModel, + ConversationScreenRouteEffects( + conversationId = conversationId, + launchGeneration = launchGeneration, + cancelIncomingNotification = cancelIncomingNotification, + pendingDraft = pendingDraft, + pendingStartupAttachment = pendingStartupAttachment, + scaffoldUiState = scaffoldUiState, snackbarHostState = snackbarHostState, hostBoundsState = hostBoundsState, + permissionState = permissionState, + screenModel = screenModel, onNavigateBack = onNavigateBack, + onPendingDraftConsumed = onPendingDraftConsumed, + onPendingStartupAttachmentConsumed = onPendingStartupAttachmentConsumed, ) - Box( - modifier = modifier - .fillMaxSize() - .onGloballyPositioned { coordinates -> - hostBoundsState.value = coordinates.boundsInWindow() - }, - ) { - ConversationScreenScaffold( - modifier = Modifier - .fillMaxSize(), - conversationId = conversationId, - uiState = scaffoldUiState, - snackbarHostState = snackbarHostState, - isMediaPickerOpen = mediaPickerState.isOpen, - messageFieldFocusRequester = messageFieldFocusRequester, - pendingScrollPosition = pendingScrollPosition, - onPendingScrollPositionConsumed = onPendingScrollPositionConsumed, - onAddPeopleClick = onAddPeopleClick, - onCallClick = screenModel::onCallClick, - onConversationDetailsClick = onConversationDetailsClick, - onNavigateBack = onNavigateBack, - onArchiveConversationClick = screenModel::onArchiveConversationClick, - onUnarchiveConversationClick = screenModel::onUnarchiveConversationClick, - onAddContactClick = screenModel::onAddContactClick, - onDeleteConversationClick = screenModel::onDeleteConversationClick, - onDeleteConversationConfirmed = screenModel::confirmDeleteConversation, - onDeleteConversationDismissed = screenModel::dismissDeleteConversationConfirmation, - onDeleteSelectedMessagesConfirmed = screenModel::confirmDeleteSelectedMessages, - onDeleteSelectedMessagesDismissed = screenModel::dismissDeleteMessageConfirmation, - onDismissMessageSelection = screenModel::dismissMessageSelection, - onMessageClick = screenModel::onMessageClick, - onMessageLongClick = screenModel::onMessageLongClick, - onMessageResendClick = screenModel::onMessageResendClick, - onMessageSelectionActionClick = screenModel::onMessageSelectionActionClick, - onOpenContactPicker = { - contactPickerLauncher.launch(input = null) - }, - onOpenMediaPicker = mediaPickerState::open, - onMessageTextChange = screenModel::onMessageTextChanged, - onPendingAttachmentRemove = screenModel::onRemovePendingAttachment, - onResolvedAttachmentClick = screenModel::onAttachmentClicked, - onResolvedAttachmentRemove = screenModel::onRemoveResolvedAttachment, - onAudioRecordingStartRequest = { - requestAudioRecordingStart(PendingAudioRecordingStartMode.Unlocked) - }, - onLockedAudioRecordingStartRequest = { - requestAudioRecordingStart(PendingAudioRecordingStartMode.Locked) - }, - onAudioRecordingFinish = screenModel::onAudioRecordingFinish, - onAudioRecordingLock = screenModel::onAudioRecordingLock, - onAudioRecordingCancel = screenModel::onAudioRecordingCancel, - onSendClick = screenModel::onSendClick, - onSimSelected = screenModel::onSimSelected, - onAttachmentClick = screenModel::onMessageAttachmentClicked, - onExternalUriClick = screenModel::onExternalUriClicked, - ) - - ConversationMediaPickerOverlay( - modifier = Modifier - .fillMaxSize(), - state = mediaPickerState, - attachments = mediaPickerOverlayUiState.attachments, - conversationTitle = mediaPickerOverlayUiState.conversationTitle, - isSendActionEnabled = mediaPickerOverlayUiState.isSendActionEnabled, - messageFieldFocusRequester = messageFieldFocusRequester, - onAttachmentPreviewClick = { attachment -> - screenModel.onAttachmentClicked(attachment = attachment) - }, - onAttachmentCaptionChange = screenModel::onUpdateAttachmentCaption, - onAttachmentRemove = screenModel::onRemoveResolvedAttachment, - photoPickerSourceContentUriByAttachmentContentUri = - mediaPickerOverlayUiState.photoPickerSourceContentUriByAttachmentContentUri, - onPhotoPickerMediaSelected = screenModel::onPhotoPickerMediaSelected, - onPhotoPickerMediaDeselected = screenModel::onPhotoPickerMediaDeselected, - onCapturedMediaReady = screenModel::onCapturedMediaReady, - onSendClick = screenModel::onSendClick, - ) - } -} - -private fun startAudioRecording( - screenModel: ConversationScreenModel, - startMode: PendingAudioRecordingStartMode, -) { - when (startMode) { - PendingAudioRecordingStartMode.None -> Unit - PendingAudioRecordingStartMode.Unlocked -> screenModel.onAudioRecordingStart() - PendingAudioRecordingStartMode.Locked -> screenModel.onLockedAudioRecordingStart() - } + ConversationScreenSurface( + modifier = modifier, + conversationId = conversationId, + scaffoldUiState = scaffoldUiState, + mediaPickerOverlayUiState = mediaPickerOverlayUiState, + mediaPickerState = mediaPickerState, + snackbarHostState = snackbarHostState, + messageFieldFocusRequester = messageFieldFocusRequester, + pendingScrollPosition = pendingScrollPosition, + onPendingScrollPositionConsumed = onPendingScrollPositionConsumed, + onAddPeopleClick = onAddPeopleClick, + onConversationDetailsClick = onConversationDetailsClick, + onNavigateBack = onNavigateBack, + onHostBoundsChanged = { hostBounds -> + hostBoundsState.value = hostBounds + }, + onOpenContactPicker = onOpenContactPicker, + onAudioRecordingStartRequest = { + requestAudioRecordingStart(PendingAudioRecordingStartMode.Unlocked) + }, + onLockedAudioRecordingStartRequest = { + requestAudioRecordingStart(PendingAudioRecordingStartMode.Locked) + }, + screenModel = screenModel, + ) } @Composable -private fun ConversationScreenScaffold( +internal fun ConversationScreenScaffold( modifier: Modifier = Modifier, conversationId: String?, uiState: ConversationScreenScaffoldUiState, @@ -321,37 +137,13 @@ private fun ConversationScreenScaffold( pendingScrollPosition: Int?, onPendingScrollPositionConsumed: () -> Unit, onAddPeopleClick: () -> Unit, - onCallClick: () -> Unit, onConversationDetailsClick: () -> Unit, - onArchiveConversationClick: () -> Unit, - onUnarchiveConversationClick: () -> Unit, - onAddContactClick: () -> Unit, - onDeleteConversationClick: () -> Unit, - onDeleteConversationConfirmed: () -> Unit, - onDeleteConversationDismissed: () -> Unit, - onDeleteSelectedMessagesConfirmed: () -> Unit, - onDeleteSelectedMessagesDismissed: () -> Unit, - onDismissMessageSelection: () -> Unit, - onMessageClick: (String) -> Unit, - onMessageLongClick: (String) -> Unit, - onMessageResendClick: (String) -> Unit, - onMessageSelectionActionClick: (ConversationMessageSelectionAction) -> Unit, onNavigateBack: () -> Unit, onOpenContactPicker: () -> Unit, onOpenMediaPicker: () -> Unit, - onMessageTextChange: (String) -> Unit, - onPendingAttachmentRemove: (String) -> Unit, - onResolvedAttachmentClick: (ComposerAttachmentUiModel.Resolved) -> Unit, - onResolvedAttachmentRemove: (String) -> Unit, onAudioRecordingStartRequest: () -> Unit, onLockedAudioRecordingStartRequest: () -> Unit, - onAudioRecordingFinish: () -> Unit, - onAudioRecordingLock: () -> Boolean, - onAudioRecordingCancel: () -> Unit, - onSendClick: () -> Unit, - onSimSelected: (String) -> Unit, - onAttachmentClick: (contentType: String, contentUri: String) -> Unit, - onExternalUriClick: (String) -> Unit, + screenModel: ConversationScreenModel, ) { var isSimSheetVisible by rememberSaveable { mutableStateOf(value = false) } @@ -364,69 +156,28 @@ private fun ConversationScreenScaffold( Scaffold( modifier = modifier, - snackbarHost = { - SnackbarHost(hostState = snackbarHostState) - }, + snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, topBar = { - when { - uiState.selection.isSelectionMode -> { - ConversationSelectionTopAppBar( - selection = uiState.selection, - onActionClick = onMessageSelectionActionClick, - onDismissSelection = onDismissMessageSelection, - ) - } - - else -> { - ConversationTopAppBar( - metadata = uiState.metadata, - isAddPeopleVisible = uiState.canAddPeople, - isCallVisible = uiState.canCall, - isArchiveVisible = uiState.canArchive, - isUnarchiveVisible = uiState.canUnarchive, - isAddContactVisible = uiState.canAddContact, - isDeleteConversationVisible = uiState.canDeleteConversation, - simSelector = uiState.composer.simSelector, - onAddPeopleClick = onAddPeopleClick, - onCallClick = onCallClick, - onArchiveClick = onArchiveConversationClick, - onUnarchiveClick = onUnarchiveConversationClick, - onAddContactClick = onAddContactClick, - onDeleteConversationClick = onDeleteConversationClick, - onSimSelectorClick = { isSimSheetVisible = true }, - onTitleClick = onConversationDetailsClick, - onNavigateBack = onNavigateBack, - ) - } - } + ConversationScreenTopBar( + uiState = uiState, + onAddPeopleClick = onAddPeopleClick, + onConversationDetailsClick = onConversationDetailsClick, + onNavigateBack = onNavigateBack, + onSimSelectorClick = { isSimSheetVisible = true }, + screenModel = screenModel, + ) }, bottomBar = { - if (!isMediaPickerOpen) { - ConversationComposerSection( - audioRecording = uiState.composer.audioRecording, - attachments = uiState.composer.attachments, - messageText = uiState.composer.messageText, - sendProtocol = uiState.composer.sendProtocol, - isMessageFieldEnabled = uiState.composer.isMessageFieldEnabled, - isAttachmentActionEnabled = uiState.composer.isAttachmentActionEnabled, - isRecordActionEnabled = uiState.composer.isRecordActionEnabled, - isSendActionEnabled = uiState.composer.isSendEnabled, - shouldShowRecordAction = uiState.composer.shouldShowRecordAction, - messageFieldFocusRequester = messageFieldFocusRequester, - onContactAttachClick = onOpenContactPicker, - onMediaPickerClick = onOpenMediaPicker, - onMessageTextChange = onMessageTextChange, - onPendingAttachmentRemove = onPendingAttachmentRemove, - onResolvedAttachmentClick = onResolvedAttachmentClick, - onResolvedAttachmentRemove = onResolvedAttachmentRemove, - onAudioRecordingStartRequest = onAudioRecordingStartRequest, - onLockedAudioRecordingStartRequest = onLockedAudioRecordingStartRequest, - onAudioRecordingFinish = onAudioRecordingFinish, - onAudioRecordingLock = onAudioRecordingLock, - onAudioRecordingCancel = onAudioRecordingCancel, - onSendClick = onSendClick, - ) - } + ConversationScreenBottomBar( + uiState = uiState, + isMediaPickerOpen = isMediaPickerOpen, + messageFieldFocusRequester = messageFieldFocusRequester, + onOpenContactPicker = onOpenContactPicker, + onOpenMediaPicker = onOpenMediaPicker, + onAudioRecordingStartRequest = onAudioRecordingStartRequest, + onLockedAudioRecordingStartRequest = onLockedAudioRecordingStartRequest, + screenModel = screenModel, + ) }, ) { contentPadding -> ConversationScreenContent( @@ -437,41 +188,147 @@ private fun ConversationScreenScaffold( contentPadding = contentPadding, pendingScrollPosition = pendingScrollPosition, onPendingScrollPositionConsumed = onPendingScrollPositionConsumed, - onAttachmentClick = onAttachmentClick, - onExternalUriClick = onExternalUriClick, - onMessageClick = onMessageClick, - onMessageLongClick = onMessageLongClick, - onMessageResendClick = onMessageResendClick, + onAttachmentClick = screenModel::onMessageAttachmentClicked, + onExternalUriClick = screenModel::onExternalUriClicked, + onMessageClick = screenModel::onMessageClick, + onMessageLongClick = screenModel::onMessageLongClick, + onMessageResendClick = screenModel::onMessageResendClick, ) } + ConversationScreenDialogs(uiState = uiState, screenModel = screenModel) + + ConversationScreenSimSelectorSheet( + isVisible = isSimSheetVisible, + uiState = uiState, + onSimSelected = screenModel::onSimSelected, + onDismissRequest = { isSimSheetVisible = false }, + ) +} + +@Composable +private fun ConversationScreenTopBar( + uiState: ConversationScreenScaffoldUiState, + onAddPeopleClick: () -> Unit, + onConversationDetailsClick: () -> Unit, + onNavigateBack: () -> Unit, + onSimSelectorClick: () -> Unit, + screenModel: ConversationScreenModel, +) { + when { + uiState.selection.isSelectionMode -> { + ConversationSelectionTopAppBar( + selection = uiState.selection, + onActionClick = screenModel::onMessageSelectionActionClick, + onDismissSelection = screenModel::dismissMessageSelection, + ) + } + + else -> { + ConversationTopAppBar( + metadata = uiState.metadata, + isAddPeopleVisible = uiState.canAddPeople, + isCallVisible = uiState.canCall, + isArchiveVisible = uiState.canArchive, + isUnarchiveVisible = uiState.canUnarchive, + isAddContactVisible = uiState.canAddContact, + isDeleteConversationVisible = uiState.canDeleteConversation, + simSelector = uiState.composer.simSelector, + onAddPeopleClick = onAddPeopleClick, + onCallClick = screenModel::onCallClick, + onArchiveClick = screenModel::onArchiveConversationClick, + onUnarchiveClick = screenModel::onUnarchiveConversationClick, + onAddContactClick = screenModel::onAddContactClick, + onDeleteConversationClick = screenModel::onDeleteConversationClick, + onSimSelectorClick = onSimSelectorClick, + onTitleClick = onConversationDetailsClick, + onNavigateBack = onNavigateBack, + ) + } + } +} + +@Composable +private fun ConversationScreenBottomBar( + uiState: ConversationScreenScaffoldUiState, + isMediaPickerOpen: Boolean, + messageFieldFocusRequester: FocusRequester, + onOpenContactPicker: () -> Unit, + onOpenMediaPicker: () -> Unit, + onAudioRecordingStartRequest: () -> Unit, + onLockedAudioRecordingStartRequest: () -> Unit, + screenModel: ConversationScreenModel, +) { + if (isMediaPickerOpen) { + return + } + + ConversationComposerSection( + audioRecording = uiState.composer.audioRecording, + attachments = uiState.composer.attachments, + messageText = uiState.composer.messageText, + sendProtocol = uiState.composer.sendProtocol, + isMessageFieldEnabled = uiState.composer.isMessageFieldEnabled, + isAttachmentActionEnabled = uiState.composer.isAttachmentActionEnabled, + isRecordActionEnabled = uiState.composer.isRecordActionEnabled, + isSendActionEnabled = uiState.composer.isSendEnabled, + shouldShowRecordAction = uiState.composer.shouldShowRecordAction, + messageFieldFocusRequester = messageFieldFocusRequester, + onContactAttachClick = onOpenContactPicker, + onMediaPickerClick = onOpenMediaPicker, + onMessageTextChange = screenModel::onMessageTextChanged, + onPendingAttachmentRemove = screenModel::onRemovePendingAttachment, + onResolvedAttachmentClick = screenModel::onAttachmentClicked, + onResolvedAttachmentRemove = screenModel::onRemoveResolvedAttachment, + onAudioRecordingStartRequest = onAudioRecordingStartRequest, + onLockedAudioRecordingStartRequest = onLockedAudioRecordingStartRequest, + onAudioRecordingFinish = screenModel::onAudioRecordingFinish, + onAudioRecordingLock = screenModel::onAudioRecordingLock, + onAudioRecordingCancel = screenModel::onAudioRecordingCancel, + onSendClick = screenModel::onSendClick, + ) +} + +@Composable +private fun ConversationScreenDialogs( + uiState: ConversationScreenScaffoldUiState, + screenModel: ConversationScreenModel, +) { uiState.selection.deleteConfirmation?.let { deleteConfirmation -> ConversationDeleteMessagesDialog( deleteConfirmation = deleteConfirmation, - onConfirm = onDeleteSelectedMessagesConfirmed, - onDismiss = onDeleteSelectedMessagesDismissed, + onConfirm = screenModel::confirmDeleteSelectedMessages, + onDismiss = screenModel::dismissDeleteMessageConfirmation, ) } if (uiState.isDeleteConversationConfirmationVisible) { ConversationDeleteConversationDialog( - onConfirm = onDeleteConversationConfirmed, - onDismiss = onDeleteConversationDismissed, + onConfirm = screenModel::confirmDeleteConversation, + onDismiss = screenModel::dismissDeleteConversationConfirmation, ) } +} - if (isSimSheetVisible && hasSimSelector) { - ConversationSimSelectorSheet( - uiState = uiState.composer.simSelector, - onSimSelected = { selfParticipantId -> - onSimSelected(selfParticipantId) - isSimSheetVisible = false - }, - onDismissRequest = { - isSimSheetVisible = false - }, - ) +@Composable +private fun ConversationScreenSimSelectorSheet( + isVisible: Boolean, + uiState: ConversationScreenScaffoldUiState, + onSimSelected: (String) -> Unit, + onDismissRequest: () -> Unit, +) { + if (!isVisible || !uiState.composer.simSelector.isAvailable) { + return } + + ConversationSimSelectorSheet( + uiState = uiState.composer.simSelector, + onSimSelected = { selfParticipantId -> + onSimSelected(selfParticipantId) + onDismissRequest() + }, + onDismissRequest = onDismissRequest, + ) } @Composable diff --git a/src/com/android/messaging/ui/conversation/v2/screen/ConversationScreenEffects.kt b/src/com/android/messaging/ui/conversation/v2/screen/ConversationScreenEffects.kt index 65c5bc7c..6951a5c9 100644 --- a/src/com/android/messaging/ui/conversation/v2/screen/ConversationScreenEffects.kt +++ b/src/com/android/messaging/ui/conversation/v2/screen/ConversationScreenEffects.kt @@ -51,103 +51,151 @@ internal fun ConversationScreenEffects( LaunchedEffect(screenModel, context, snackbarHostState, hostBoundsState, onNavigateBack) { screenModel.effects.collect { effect -> - when (effect) { - ConversationScreenEffect.CloseConversation -> { - onNavigateBack() - } - - is ConversationScreenEffect.RequestDefaultSmsRole -> { - requestDefaultSmsRole( - context = context, - snackbarHostState = snackbarHostState, - effect = effect, - onActionClick = screenModel::onDefaultSmsRolePromptActionClick, - ) - } - - is ConversationScreenEffect.LaunchAddContactFlow -> { - UIIntents.get().launchAddContactActivity( - context, - effect.destination, - ) - } - - is ConversationScreenEffect.OpenAttachmentPreview -> { - openAttachmentPreview( - context = context, - hostBounds = hostBoundsState.value, - contentUri = effect.contentUri, - contentType = effect.contentType, - imageCollectionUri = effect.imageCollectionUri, - awaitHostBounds = { - snapshotFlow { hostBoundsState.value } - .filterNotNull() - .first() - }, - ) - } - - is ConversationScreenEffect.OpenExternalUri -> { - openExternalUri( - context = context, - uri = effect.uri, - ) - } - - is ConversationScreenEffect.PlacePhoneCall -> { - placePhoneCall( - context = context, - phoneNumber = effect.phoneNumber, - ) - } - - is ConversationScreenEffect.ShowSaveAttachmentsResult -> { - showSaveAttachmentsResultToast( - context = context, - effect = effect, - ) - } - - is ConversationScreenEffect.LaunchDefaultSmsRoleRequest -> { - launchDefaultSmsRoleRequest( - effect = effect, - launchRoleRequest = { intent -> - defaultSmsRoleLauncher.launch(intent) - }, - onLaunchFailed = screenModel::onDefaultSmsRoleRequestLaunchFailed, - ) - } - - is ConversationScreenEffect.LaunchForwardMessage -> { - UIIntents.get().launchForwardMessageActivity( - context, - effect.message, - ) - } - - is ConversationScreenEffect.ShareMessage -> { - openShareSheet( - context = context, - attachmentContentType = effect.attachmentContentType, - attachmentContentUri = effect.attachmentContentUri, - text = effect.text, - ) - } - - is ConversationScreenEffect.ShowMessage -> { - UiUtils.showToastAtBottom(effect.messageResId) - } - - is ConversationScreenEffect.ShowMessageDetails -> { - MessageDetailsDialog.show( - context, - effect.message, - effect.participants, - effect.selfParticipant, - ) - } - } + screenModel.handleConversationScreenEffect( + context = context, + snackbarHostState = snackbarHostState, + hostBoundsState = hostBoundsState, + effect = effect, + launchRoleRequest = defaultSmsRoleLauncher::launch, + onNavigateBack = onNavigateBack, + ) + } + } +} + +private suspend fun ConversationScreenModel.handleConversationScreenEffect( + context: Context, + snackbarHostState: SnackbarHostState, + hostBoundsState: State, + effect: ConversationScreenEffect, + launchRoleRequest: (Intent) -> Unit, + onNavigateBack: () -> Unit, +) { + when (effect) { + ConversationScreenEffect.CloseConversation -> onNavigateBack() + is ConversationScreenEffect.RequestDefaultSmsRole -> { + requestDefaultSmsRole( + context = context, + snackbarHostState = snackbarHostState, + effect = effect, + onActionClick = ::onDefaultSmsRolePromptActionClick, + ) + } + + is ConversationScreenEffect.LaunchDefaultSmsRoleRequest -> { + launchDefaultSmsRoleRequest( + effect = effect, + launchRoleRequest = launchRoleRequest, + onLaunchFailed = ::onDefaultSmsRoleRequestLaunchFailed, + ) + } + + is ConversationScreenEffect.OpenAttachmentPreview -> { + openAttachmentPreviewEffect( + context = context, + hostBoundsState = hostBoundsState, + effect = effect, + ) + } + + is ConversationScreenEffect.ShareMessage -> { + openShareSheet( + context = context, + attachmentContentType = effect.attachmentContentType, + attachmentContentUri = effect.attachmentContentUri, + text = effect.text, + ) } + + is ConversationScreenEffect.LaunchAddContactFlow, + is ConversationScreenEffect.LaunchForwardMessage, + is ConversationScreenEffect.OpenExternalUri, + is ConversationScreenEffect.PlacePhoneCall, + is ConversationScreenEffect.ShowMessage, + is ConversationScreenEffect.ShowMessageDetails, + is ConversationScreenEffect.ShowSaveAttachmentsResult, + -> { + handleImmediateConversationScreenEffect( + context = context, + effect = effect, + ) + } + } +} + +private suspend fun openAttachmentPreviewEffect( + context: Context, + hostBoundsState: State, + effect: ConversationScreenEffect.OpenAttachmentPreview, +) { + openAttachmentPreview( + context = context, + hostBounds = hostBoundsState.value, + contentUri = effect.contentUri, + contentType = effect.contentType, + imageCollectionUri = effect.imageCollectionUri, + awaitHostBounds = { + snapshotFlow { hostBoundsState.value } + .filterNotNull() + .first() + }, + ) +} + +private fun handleImmediateConversationScreenEffect( + context: Context, + effect: ConversationScreenEffect, +) { + when (effect) { + is ConversationScreenEffect.LaunchAddContactFlow -> { + UIIntents.get().launchAddContactActivity( + context, + effect.destination, + ) + } + + is ConversationScreenEffect.LaunchForwardMessage -> { + UIIntents.get().launchForwardMessageActivity( + context, + effect.message, + ) + } + + is ConversationScreenEffect.OpenExternalUri -> { + openExternalUri( + context = context, + uri = effect.uri, + ) + } + + is ConversationScreenEffect.PlacePhoneCall -> { + placePhoneCall( + context = context, + phoneNumber = effect.phoneNumber, + ) + } + + is ConversationScreenEffect.ShowMessage -> { + UiUtils.showToastAtBottom(effect.messageResId) + } + + is ConversationScreenEffect.ShowMessageDetails -> { + MessageDetailsDialog.show( + context, + effect.message, + effect.participants, + effect.selfParticipant, + ) + } + + is ConversationScreenEffect.ShowSaveAttachmentsResult -> { + showSaveAttachmentsResultToast( + context = context, + effect = effect, + ) + } + + else -> Unit } } diff --git a/src/com/android/messaging/ui/conversation/v2/screen/ConversationScreenRoute.kt b/src/com/android/messaging/ui/conversation/v2/screen/ConversationScreenRoute.kt new file mode 100644 index 00000000..b10a9ec9 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/v2/screen/ConversationScreenRoute.kt @@ -0,0 +1,309 @@ +package com.android.messaging.ui.conversation.v2.screen + +import android.Manifest +import androidx.activity.compose.BackHandler +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.SnackbarHostState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.State +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +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.geometry.Rect as ComposeRect +import androidx.compose.ui.layout.boundsInWindow +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LifecycleEventEffect +import com.android.messaging.data.conversation.model.draft.ConversationDraft +import com.android.messaging.ui.conversation.v2.audio.model.ConversationAudioRecordingPhase +import com.android.messaging.ui.conversation.v2.entry.model.ConversationEntryStartupAttachment +import com.android.messaging.ui.conversation.v2.mediapicker.ConversationMediaPickerOverlay +import com.android.messaging.ui.conversation.v2.mediapicker.ConversationMediaPickerPermissionState +import com.android.messaging.ui.conversation.v2.mediapicker.ConversationMediaPickerState +import com.android.messaging.ui.conversation.v2.mediapicker.RefreshConversationMediaPickerPermissionsEffect +import com.android.messaging.ui.conversation.v2.screen.model.ConversationMediaPickerOverlayUiState +import com.android.messaging.ui.conversation.v2.screen.model.ConversationScreenScaffoldUiState + +@Composable +internal fun rememberOpenContactPickerCallback( + screenModel: ConversationScreenModel, +): () -> Unit { + val contactPickerLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.PickContact(), + ) { contactUri -> + screenModel.onContactCardPicked(contactUri = contactUri?.toString()) + } + + return remember(contactPickerLauncher) { + { + contactPickerLauncher.launch(input = null) + } + } +} + +@Composable +internal fun rememberAudioRecordingStartRequest( + screenModel: ConversationScreenModel, + permissionState: ConversationMediaPickerPermissionState, +): (PendingAudioRecordingStartMode) -> Unit { + var pendingAudioRecordingStartMode by rememberSaveable { + mutableStateOf(value = PendingAudioRecordingStartMode.None) + } + + val audioPermissionLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestPermission(), + ) { isGranted -> + permissionState.audioPermissionGranted = isGranted + + val startMode = pendingAudioRecordingStartMode + pendingAudioRecordingStartMode = PendingAudioRecordingStartMode.None + + if (isGranted) { + startAudioRecording( + screenModel = screenModel, + startMode = startMode, + ) + } + } + + return remember(screenModel, permissionState, audioPermissionLauncher) { + { startMode -> + if (permissionState.audioPermissionGranted) { + startAudioRecording( + screenModel = screenModel, + startMode = startMode, + ) + } else { + pendingAudioRecordingStartMode = startMode + audioPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO) + } + } + } +} + +@Composable +internal fun ConversationScreenRouteEffects( + conversationId: String?, + launchGeneration: Int?, + cancelIncomingNotification: Boolean, + pendingDraft: ConversationDraft?, + pendingStartupAttachment: ConversationEntryStartupAttachment?, + scaffoldUiState: ConversationScreenScaffoldUiState, + snackbarHostState: SnackbarHostState, + hostBoundsState: State, + permissionState: ConversationMediaPickerPermissionState, + screenModel: ConversationScreenModel, + onNavigateBack: () -> Unit, + onPendingDraftConsumed: () -> Unit, + onPendingStartupAttachmentConsumed: () -> Unit, +) { + ConversationPendingLaunchEffects( + conversationId = conversationId, + launchGeneration = launchGeneration, + pendingDraft = pendingDraft, + pendingStartupAttachment = pendingStartupAttachment, + screenModel = screenModel, + onPendingDraftConsumed = onPendingDraftConsumed, + onPendingStartupAttachmentConsumed = onPendingStartupAttachmentConsumed, + ) + + RefreshConversationMediaPickerPermissionsEffect( + permissionState = permissionState, + ) + + ConversationScreenLifecycleEffects( + cancelIncomingNotification = cancelIncomingNotification, + uiState = scaffoldUiState, + screenModel = screenModel, + ) + + BackHandler(enabled = scaffoldUiState.selection.isSelectionMode) { + screenModel.dismissMessageSelection() + } + + ConversationScreenEffects( + screenModel = screenModel, + snackbarHostState = snackbarHostState, + hostBoundsState = hostBoundsState, + onNavigateBack = onNavigateBack, + ) +} + +@Composable +private fun ConversationPendingLaunchEffects( + conversationId: String?, + launchGeneration: Int?, + pendingDraft: ConversationDraft?, + pendingStartupAttachment: ConversationEntryStartupAttachment?, + screenModel: ConversationScreenModel, + onPendingDraftConsumed: () -> Unit, + onPendingStartupAttachmentConsumed: () -> Unit, +) { + LaunchedEffect(conversationId, screenModel) { + screenModel.onConversationIdChanged(conversationId = conversationId) + } + + LaunchedEffect(conversationId, launchGeneration, pendingDraft, screenModel) { + if (conversationId != null && launchGeneration != null && pendingDraft != null) { + screenModel.onSeedDraft( + conversationId = conversationId, + draft = pendingDraft, + ) + onPendingDraftConsumed() + } + } + + LaunchedEffect( + conversationId, + launchGeneration, + pendingStartupAttachment, + screenModel, + ) { + if ( + conversationId != null && + launchGeneration != null && + pendingStartupAttachment != null + ) { + screenModel.onOpenStartupAttachment( + conversationId = conversationId, + startupAttachment = pendingStartupAttachment, + ) + onPendingStartupAttachmentConsumed() + } + } +} + +@Composable +private fun ConversationScreenLifecycleEffects( + cancelIncomingNotification: Boolean, + uiState: ConversationScreenScaffoldUiState, + screenModel: ConversationScreenModel, +) { + LifecycleEventEffect(event = Lifecycle.Event.ON_RESUME) { + screenModel.onScreenForegrounded(cancelNotification = cancelIncomingNotification) + } + + LifecycleEventEffect(event = Lifecycle.Event.ON_PAUSE) { + screenModel.onScreenBackgrounded() + } + + LifecycleEventEffect(event = Lifecycle.Event.ON_STOP) { + val isRecording = uiState.composer.audioRecording.phase == + ConversationAudioRecordingPhase.Recording + + if (isRecording) { + screenModel.onAudioRecordingCancel() + } + screenModel.persistDraft() + } +} + +@Composable +internal fun ConversationScreenSurface( + modifier: Modifier, + conversationId: String?, + scaffoldUiState: ConversationScreenScaffoldUiState, + mediaPickerOverlayUiState: ConversationMediaPickerOverlayUiState, + mediaPickerState: ConversationMediaPickerState, + snackbarHostState: SnackbarHostState, + messageFieldFocusRequester: FocusRequester, + pendingScrollPosition: Int?, + onPendingScrollPositionConsumed: () -> Unit, + onAddPeopleClick: () -> Unit, + onConversationDetailsClick: () -> Unit, + onNavigateBack: () -> Unit, + onHostBoundsChanged: (ComposeRect) -> Unit, + onOpenContactPicker: () -> Unit, + onAudioRecordingStartRequest: () -> Unit, + onLockedAudioRecordingStartRequest: () -> Unit, + screenModel: ConversationScreenModel, +) { + Box( + modifier = modifier + .fillMaxSize() + .onGloballyPositioned { coordinates -> + onHostBoundsChanged(coordinates.boundsInWindow()) + }, + ) { + ConversationScreenScaffold( + modifier = Modifier.fillMaxSize(), + conversationId = conversationId, + uiState = scaffoldUiState, + snackbarHostState = snackbarHostState, + isMediaPickerOpen = mediaPickerState.isOpen, + messageFieldFocusRequester = messageFieldFocusRequester, + pendingScrollPosition = pendingScrollPosition, + onPendingScrollPositionConsumed = onPendingScrollPositionConsumed, + onAddPeopleClick = onAddPeopleClick, + onConversationDetailsClick = onConversationDetailsClick, + onNavigateBack = onNavigateBack, + onOpenContactPicker = onOpenContactPicker, + onOpenMediaPicker = mediaPickerState::open, + onAudioRecordingStartRequest = onAudioRecordingStartRequest, + onLockedAudioRecordingStartRequest = onLockedAudioRecordingStartRequest, + screenModel = screenModel, + ) + + ConversationMediaPickerOverlayHost( + modifier = Modifier.fillMaxSize(), + uiState = mediaPickerOverlayUiState, + state = mediaPickerState, + messageFieldFocusRequester = messageFieldFocusRequester, + screenModel = screenModel, + ) + } +} + +@Composable +private fun ConversationMediaPickerOverlayHost( + modifier: Modifier, + uiState: ConversationMediaPickerOverlayUiState, + state: ConversationMediaPickerState, + messageFieldFocusRequester: FocusRequester, + screenModel: ConversationScreenModel, +) { + ConversationMediaPickerOverlay( + modifier = modifier, + state = state, + attachments = uiState.attachments, + conversationTitle = uiState.conversationTitle, + isSendActionEnabled = uiState.isSendActionEnabled, + messageFieldFocusRequester = messageFieldFocusRequester, + onAttachmentPreviewClick = { attachment -> + screenModel.onAttachmentClicked(attachment = attachment) + }, + onAttachmentCaptionChange = screenModel::onUpdateAttachmentCaption, + onAttachmentRemove = screenModel::onRemoveResolvedAttachment, + photoPickerSourceContentUriByAttachmentContentUri = + uiState.photoPickerSourceContentUriByAttachmentContentUri, + onPhotoPickerMediaSelected = screenModel::onPhotoPickerMediaSelected, + onPhotoPickerMediaDeselected = screenModel::onPhotoPickerMediaDeselected, + onCapturedMediaReady = screenModel::onCapturedMediaReady, + onSendClick = screenModel::onSendClick, + ) +} + +private fun startAudioRecording( + screenModel: ConversationScreenModel, + startMode: PendingAudioRecordingStartMode, +) { + when (startMode) { + PendingAudioRecordingStartMode.None -> {} + + PendingAudioRecordingStartMode.Unlocked -> { + screenModel.onAudioRecordingStart() + } + + PendingAudioRecordingStartMode.Locked -> { + screenModel.onLockedAudioRecordingStart() + } + } +} diff --git a/src/com/android/messaging/ui/conversation/v2/screen/ConversationSelectionTopAppBar.kt b/src/com/android/messaging/ui/conversation/v2/screen/ConversationSelectionTopAppBar.kt index a8b9fae3..049be3e4 100644 --- a/src/com/android/messaging/ui/conversation/v2/screen/ConversationSelectionTopAppBar.kt +++ b/src/com/android/messaging/ui/conversation/v2/screen/ConversationSelectionTopAppBar.kt @@ -32,6 +32,24 @@ import androidx.compose.ui.res.stringResource import com.android.messaging.R import com.android.messaging.ui.conversation.v2.screen.model.ConversationMessageSelectionAction import com.android.messaging.ui.conversation.v2.screen.model.ConversationMessageSelectionUiState +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.ImmutableSet +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toPersistentList + +private val messageSelectionActions = persistentListOf( + ConversationMessageSelectionAction.Download, + ConversationMessageSelectionAction.Resend, + ConversationMessageSelectionAction.Copy, + ConversationMessageSelectionAction.Delete, +) + +private val conversationMessageSelectionActions = persistentListOf( + ConversationMessageSelectionAction.Share, + ConversationMessageSelectionAction.Forward, + ConversationMessageSelectionAction.SaveAttachment, + ConversationMessageSelectionAction.Details, +) @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -44,125 +62,145 @@ internal fun ConversationSelectionTopAppBar( mutableStateOf(value = false) } - val overflowActions = remember(selection.availableActions) { - buildList { - if (selection.availableActions.contains(ConversationMessageSelectionAction.Share)) { - add(ConversationMessageSelectionAction.Share) - } - - if (selection.availableActions.contains(ConversationMessageSelectionAction.Forward)) { - add(ConversationMessageSelectionAction.Forward) - } - - val hasSaveAttachmentAction = selection.availableActions.contains( - ConversationMessageSelectionAction.SaveAttachment, - ) - - if (hasSaveAttachmentAction) { - add(ConversationMessageSelectionAction.SaveAttachment) - } - - if (selection.availableActions.contains(ConversationMessageSelectionAction.Details)) { - add(ConversationMessageSelectionAction.Details) - } - } + val availableActions = selection.availableActions + val overflowActions = remember(availableActions) { + selectionActionsInOrder( + availableActions = availableActions, + orderedActions = conversationMessageSelectionActions, + ) } TopAppBar( colors = conversationSelectionTopAppBarColors(), title = { - Text( - text = pluralStringResource( - id = R.plurals.conversation_message_selection_title, - count = selection.selectedMessageCount, - selection.selectedMessageCount, - ), - ) + ConversationSelectionTitle(selectedMessageCount = selection.selectedMessageCount) }, navigationIcon = { - IconButton( - onClick = onDismissSelection, - ) { - Icon( - imageVector = Icons.Rounded.Close, - contentDescription = stringResource( - id = R.string.close_selection, - ), - ) - } + ConversationSelectionNavigationIcon(onDismissSelection = onDismissSelection) }, actions = { - if (selection.availableActions.contains(ConversationMessageSelectionAction.Download)) { - ConversationSelectionActionButton( - action = ConversationMessageSelectionAction.Download, - onActionClick = onActionClick, - ) - } - - if (selection.availableActions.contains(ConversationMessageSelectionAction.Resend)) { - ConversationSelectionActionButton( - action = ConversationMessageSelectionAction.Resend, - onActionClick = onActionClick, - ) - } - - if (selection.availableActions.contains(ConversationMessageSelectionAction.Copy)) { - ConversationSelectionActionButton( - action = ConversationMessageSelectionAction.Copy, - onActionClick = onActionClick, - ) - } - - if (selection.availableActions.contains(ConversationMessageSelectionAction.Delete)) { - ConversationSelectionActionButton( - action = ConversationMessageSelectionAction.Delete, - onActionClick = onActionClick, - ) - } - - if (overflowActions.isNotEmpty()) { - IconButton( - onClick = { - isOverflowExpanded = true - }, - ) { - Icon( - imageVector = Icons.Rounded.MoreVert, - contentDescription = stringResource( - id = R.string.more_options, - ), - ) - } - - DropdownMenu( - expanded = isOverflowExpanded, - onDismissRequest = { - isOverflowExpanded = false - }, - ) { - overflowActions.forEach { action -> - DropdownMenuItem( - text = { - Text(text = selectionActionLabel(action = action)) - }, - onClick = { - isOverflowExpanded = false - onActionClick(action) - }, - leadingIcon = { - Icon( - imageVector = selectionActionIcon(action = action), - contentDescription = null, - ) - }, - ) - } - } - } + ConversationSelectionActions( + availableActions = availableActions, + overflowActions = overflowActions, + isOverflowExpanded = isOverflowExpanded, + onOverflowExpandedChange = { isExpanded -> + isOverflowExpanded = isExpanded + }, + onActionClick = onActionClick, + ) }, ) } +@Composable +private fun ConversationSelectionTitle(selectedMessageCount: Int) { + Text( + text = pluralStringResource( + id = R.plurals.conversation_message_selection_title, + count = selectedMessageCount, + selectedMessageCount, + ), + ) +} + +@Composable +private fun ConversationSelectionNavigationIcon(onDismissSelection: () -> Unit) { + IconButton( + onClick = onDismissSelection, + ) { + Icon( + imageVector = Icons.Rounded.Close, + contentDescription = stringResource( + id = R.string.close_selection, + ), + ) + } +} + +@Composable +private fun ConversationSelectionActions( + availableActions: ImmutableSet, + overflowActions: ImmutableList, + isOverflowExpanded: Boolean, + onOverflowExpandedChange: (Boolean) -> Unit, + onActionClick: (ConversationMessageSelectionAction) -> Unit, +) { + val primaryActions = remember(availableActions) { + selectionActionsInOrder( + availableActions = availableActions, + orderedActions = messageSelectionActions, + ) + } + + primaryActions.forEach { action -> + ConversationSelectionActionButton( + action = action, + onActionClick = onActionClick, + ) + } + + if (overflowActions.isNotEmpty()) { + ConversationSelectionOverflowButton( + onClick = { + onOverflowExpandedChange(true) + }, + ) + ConversationSelectionOverflowMenu( + actions = overflowActions, + expanded = isOverflowExpanded, + onDismissRequest = { + onOverflowExpandedChange(false) + }, + onActionClick = onActionClick, + ) + } +} + +@Composable +private fun ConversationSelectionOverflowButton(onClick: () -> Unit) { + IconButton( + onClick = onClick, + ) { + Icon( + imageVector = Icons.Rounded.MoreVert, + contentDescription = stringResource( + id = R.string.more_options, + ), + ) + } +} + +@Composable +private fun ConversationSelectionOverflowMenu( + actions: ImmutableList, + expanded: Boolean, + onDismissRequest: () -> Unit, + onActionClick: (ConversationMessageSelectionAction) -> Unit, +) { + DropdownMenu( + expanded = expanded, + onDismissRequest = onDismissRequest, + ) { + actions.forEach { action -> + DropdownMenuItem( + text = { + Text(text = selectionActionLabel(action = action)) + }, + onClick = { + onDismissRequest() + onActionClick(action) + }, + leadingIcon = { + Icon( + imageVector = selectionActionIcon(action = action), + contentDescription = null, + ) + }, + ) + } + } +} + @Composable private fun conversationSelectionTopAppBarColors(): TopAppBarColors { return TopAppBarDefaults.topAppBarColors( @@ -190,6 +228,15 @@ private fun ConversationSelectionActionButton( } } +private fun selectionActionsInOrder( + availableActions: ImmutableSet, + orderedActions: ImmutableList, +): ImmutableList { + return orderedActions.filter { action -> + availableActions.contains(action) + }.toPersistentList() +} + private fun selectionActionIcon( action: ConversationMessageSelectionAction, ): ImageVector { diff --git a/src/com/android/messaging/ui/conversation/v2/screen/PendingAudioRecordingStartMode.kt b/src/com/android/messaging/ui/conversation/v2/screen/PendingAudioRecordingStartMode.kt new file mode 100644 index 00000000..be70bd8d --- /dev/null +++ b/src/com/android/messaging/ui/conversation/v2/screen/PendingAudioRecordingStartMode.kt @@ -0,0 +1,7 @@ +package com.android.messaging.ui.conversation.v2.screen + +internal enum class PendingAudioRecordingStartMode { + None, + Unlocked, + Locked, +} From 6b858d93ad89dbb21577f61762494dfdb659a3e8 Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Thu, 30 Apr 2026 19:37:04 +0300 Subject: [PATCH 83/99] Suppress TooGenericExceptionCaught where generic exceptions are needed --- .../conversation/repository/ConversationDraftsRepository.kt | 5 +++-- .../conversation/usecase/draft/SendConversationDraft.kt | 1 + .../v2/audio/delegate/ConversationAudioRecordingDelegate.kt | 5 +++-- .../v2/composer/delegate/ConversationDraftDelegate.kt | 3 ++- .../v2/mediapicker/camera/ConversationCameraController.kt | 5 +++-- .../repository/ConversationAttachmentRepository.kt | 4 ++++ 6 files changed, 16 insertions(+), 7 deletions(-) diff --git a/src/com/android/messaging/data/conversation/repository/ConversationDraftsRepository.kt b/src/com/android/messaging/data/conversation/repository/ConversationDraftsRepository.kt index a8103c99..c53cf188 100644 --- a/src/com/android/messaging/data/conversation/repository/ConversationDraftsRepository.kt +++ b/src/com/android/messaging/data/conversation/repository/ConversationDraftsRepository.kt @@ -187,6 +187,7 @@ internal class ConversationDraftsRepositoryImpl @Inject constructor( } } + @Suppress("TooGenericExceptionCaught") private fun resolveAudioDurationMillis(contentUri: String): Long { val mediaMetadataRetrieverWrapper = MediaMetadataRetrieverWrapper() @@ -197,11 +198,11 @@ internal class ConversationDraftsRepositoryImpl @Inject constructor( ?.toLongOrNull() ?.coerceAtLeast(minimumValue = 0L) ?: 0L - } catch (throwable: Throwable) { + } catch (exception: Exception) { LogUtil.w( TAG, "Failed to resolve draft audio duration for $contentUri", - throwable, + exception, ) 0L diff --git a/src/com/android/messaging/domain/conversation/usecase/draft/SendConversationDraft.kt b/src/com/android/messaging/domain/conversation/usecase/draft/SendConversationDraft.kt index 39db0bf9..417fa216 100644 --- a/src/com/android/messaging/domain/conversation/usecase/draft/SendConversationDraft.kt +++ b/src/com/android/messaging/domain/conversation/usecase/draft/SendConversationDraft.kt @@ -44,6 +44,7 @@ internal class SendConversationDraftImpl @Inject constructor( private val ioDispatcher: CoroutineDispatcher, ) : SendConversationDraft { + @Suppress("TooGenericExceptionCaught") override operator fun invoke( conversationId: String, draft: ConversationDraft, diff --git a/src/com/android/messaging/ui/conversation/v2/audio/delegate/ConversationAudioRecordingDelegate.kt b/src/com/android/messaging/ui/conversation/v2/audio/delegate/ConversationAudioRecordingDelegate.kt index 32e9bc27..a3b219c9 100644 --- a/src/com/android/messaging/ui/conversation/v2/audio/delegate/ConversationAudioRecordingDelegate.kt +++ b/src/com/android/messaging/ui/conversation/v2/audio/delegate/ConversationAudioRecordingDelegate.kt @@ -591,11 +591,12 @@ internal class ConversationAudioRecordingDelegateImpl @Inject constructor( return true } + @Suppress("TooGenericExceptionCaught") private fun stopRecording(mediaRecorder: LevelTrackingMediaRecorder): Uri? { return try { mediaRecorder.stopRecording() - } catch (throwable: Throwable) { - LogUtil.w(TAG, "Failed to stop audio recording", throwable) + } catch (exception: Exception) { + LogUtil.w(TAG, "Failed to stop audio recording", exception) null } } diff --git a/src/com/android/messaging/ui/conversation/v2/composer/delegate/ConversationDraftDelegate.kt b/src/com/android/messaging/ui/conversation/v2/composer/delegate/ConversationDraftDelegate.kt index 40e8babb..d16bf46a 100644 --- a/src/com/android/messaging/ui/conversation/v2/composer/delegate/ConversationDraftDelegate.kt +++ b/src/com/android/messaging/ui/conversation/v2/composer/delegate/ConversationDraftDelegate.kt @@ -585,6 +585,7 @@ internal class ConversationDraftDelegateImpl @Inject constructor( .distinctUntilChanged() } + @Suppress("TooGenericExceptionCaught") private suspend fun resolveDraftSendProtocol( conversationId: String?, draft: ConversationDraft, @@ -615,7 +616,7 @@ internal class ConversationDraftDelegateImpl @Inject constructor( } } catch (exception: CancellationException) { throw exception - } catch (exception: Throwable) { + } catch (exception: Exception) { LogUtil.e( TAG, "Failed to resolve draft send protocol for conversation $conversationId", diff --git a/src/com/android/messaging/ui/conversation/v2/mediapicker/camera/ConversationCameraController.kt b/src/com/android/messaging/ui/conversation/v2/mediapicker/camera/ConversationCameraController.kt index c965f41e..8129ccd0 100644 --- a/src/com/android/messaging/ui/conversation/v2/mediapicker/camera/ConversationCameraController.kt +++ b/src/com/android/messaging/ui/conversation/v2/mediapicker/camera/ConversationCameraController.kt @@ -243,6 +243,7 @@ private class ConversationCameraControllerImpl( ) } + @Suppress("TooGenericExceptionCaught") private fun handleCameraProviderReady( cameraProviderFuture: ListenableFuture, lifecycleOwner: LifecycleOwner, @@ -263,8 +264,8 @@ private class ConversationCameraControllerImpl( lifecycleOwner = lifecycleOwner, processCameraProvider = processCameraProvider, ) - } catch (throwable: Throwable) { - onError(throwable) + } catch (exception: Exception) { + onError(exception) } } diff --git a/src/com/android/messaging/ui/conversation/v2/mediapicker/repository/ConversationAttachmentRepository.kt b/src/com/android/messaging/ui/conversation/v2/mediapicker/repository/ConversationAttachmentRepository.kt index 735af66f..512bcdfe 100644 --- a/src/com/android/messaging/ui/conversation/v2/mediapicker/repository/ConversationAttachmentRepository.kt +++ b/src/com/android/messaging/ui/conversation/v2/mediapicker/repository/ConversationAttachmentRepository.kt @@ -54,6 +54,7 @@ internal class ConversationAttachmentRepositoryImpl @Inject constructor( private val ioDispatcher: CoroutineDispatcher, ) : ConversationAttachmentRepository { + @Suppress("TooGenericExceptionCaught") override fun createDraftAttachmentsFromPhotoPicker( contentUris: List, ): Flow { @@ -247,6 +248,7 @@ internal class ConversationAttachmentRepositoryImpl @Inject constructor( return copied } + @Suppress("TooGenericExceptionCaught") private fun insertPendingRow( contentType: String, target: MediaStoreTarget, @@ -424,6 +426,7 @@ internal class ConversationAttachmentRepositoryImpl @Inject constructor( } } + @Suppress("TooGenericExceptionCaught") private fun resolveImageAttachmentMetadata(uri: Uri): VisualAttachmentMetadata { val decodeBoundsOptions = BitmapFactory.Options().apply { inJustDecodeBounds = true @@ -444,6 +447,7 @@ internal class ConversationAttachmentRepositoryImpl @Inject constructor( ) } + @Suppress("TooGenericExceptionCaught") private fun resolveVideoAttachmentMetadata(uri: Uri): VisualAttachmentMetadata { val retriever = MediaMetadataRetriever() From 68a29cdda437e73226810aad668b9fdb8d42db1c Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Thu, 30 Apr 2026 19:41:56 +0300 Subject: [PATCH 84/99] Fix TooManyFunctions error --- .../usecase/draft/SendConversationDraft.kt | 26 ++++++++++++++----- 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/src/com/android/messaging/domain/conversation/usecase/draft/SendConversationDraft.kt b/src/com/android/messaging/domain/conversation/usecase/draft/SendConversationDraft.kt index 417fa216..9241627e 100644 --- a/src/com/android/messaging/domain/conversation/usecase/draft/SendConversationDraft.kt +++ b/src/com/android/messaging/domain/conversation/usecase/draft/SendConversationDraft.kt @@ -55,19 +55,33 @@ internal class SendConversationDraftImpl @Inject constructor( conversationId = conversationId, draft = draft, ) - } catch (exception: CancellationException) { - throw exception - } catch (exception: SendConversationDraftException) { - throw exception } catch (exception: Exception) { - throw DraftDispatchFailedException( + if (exception is CancellationException) { + throw exception + } + + throw exception.toSendConversationDraftException( conversationId = conversationId, - cause = exception, ) } }.flowOn(ioDispatcher) } + private fun Exception.toSendConversationDraftException( + conversationId: String, + ): SendConversationDraftException { + return when (this) { + is SendConversationDraftException -> this + + else -> { + DraftDispatchFailedException( + conversationId = conversationId, + cause = this, + ) + } + } + } + private fun validateAndSendDraft( conversationId: String, draft: ConversationDraft, From 9ff3b5e154800d40a2c7bf50ed5423b3228327f5 Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Thu, 30 Apr 2026 19:50:23 +0300 Subject: [PATCH 85/99] Fix MagicNumbers --- .../v2/audio/ConversationAudioDurationFormatter.kt | 9 ++++++--- .../mediapicker/camera/ConversationCameraController.kt | 7 ++++++- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/src/com/android/messaging/ui/conversation/v2/audio/ConversationAudioDurationFormatter.kt b/src/com/android/messaging/ui/conversation/v2/audio/ConversationAudioDurationFormatter.kt index 795806d9..a315f277 100644 --- a/src/com/android/messaging/ui/conversation/v2/audio/ConversationAudioDurationFormatter.kt +++ b/src/com/android/messaging/ui/conversation/v2/audio/ConversationAudioDurationFormatter.kt @@ -2,10 +2,13 @@ package com.android.messaging.ui.conversation.v2.audio import java.util.Locale +private const val MILLIS_PER_SECOND = 1_000L +private const val SECONDS_PER_MINUTE = 60L + internal fun formatConversationAudioDuration(durationMillis: Long): String { - val totalSeconds = durationMillis / 1_000L - val minutes = totalSeconds / 60L - val seconds = totalSeconds % 60L + val totalSeconds = durationMillis / MILLIS_PER_SECOND + val minutes = totalSeconds / SECONDS_PER_MINUTE + val seconds = totalSeconds % SECONDS_PER_MINUTE return String.format( Locale.getDefault(), diff --git a/src/com/android/messaging/ui/conversation/v2/mediapicker/camera/ConversationCameraController.kt b/src/com/android/messaging/ui/conversation/v2/mediapicker/camera/ConversationCameraController.kt index 8129ccd0..85a53019 100644 --- a/src/com/android/messaging/ui/conversation/v2/mediapicker/camera/ConversationCameraController.kt +++ b/src/com/android/messaging/ui/conversation/v2/mediapicker/camera/ConversationCameraController.kt @@ -529,7 +529,8 @@ private class ConversationCameraControllerImpl( } private fun handleVideoRecordingStatus(event: VideoRecordEvent.Status) { - _recordingDurationMillis.value = event.recordingStats.recordedDurationNanos / 1_000_000L + _recordingDurationMillis.value = + event.recordingStats.recordedDurationNanos / NANOS_PER_MILLISECOND } private fun handleVideoRecordingFinalized( @@ -761,6 +762,10 @@ private class ConversationCameraControllerImpl( return mimeTypeExtension ?: ContentType.getExtension(contentType) } + + private companion object { + private const val NANOS_PER_MILLISECOND = 1_000_000L + } } @Composable From ce231e774d32a5946231c47ee320daf47f812a89 Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Thu, 30 Apr 2026 19:59:38 +0300 Subject: [PATCH 86/99] Fix MatchingDeclarationName errors --- ...onversationSendActionButtonGestureState.kt | 9 ++++ .../model/ConversationSendActionButtonMode.kt | 10 ++++ .../v2/composer/ui/ConversationComposeBar.kt | 2 + .../ui/ConversationSendActionButton.kt | 9 +--- .../ui/ConversationSendActionButtonGesture.kt | 9 +--- .../ConversationMediaPickerPermission.kt | 46 +----------------- .../review/ConversationMediaPickerReview.kt | 2 +- .../ConversationMediaPickerPermissionState.kt | 47 +++++++++++++++++++ .../model/text/ConversationTextLink.kt | 10 ++++ .../ui/text/ConversationMessageText.kt | 1 + .../ConversationMessageTextLinkExtractor.kt | 9 +--- .../v2/screen/ConversationScreenRoute.kt | 2 +- 12 files changed, 87 insertions(+), 69 deletions(-) create mode 100644 src/com/android/messaging/ui/conversation/v2/composer/model/ConversationSendActionButtonGestureState.kt create mode 100644 src/com/android/messaging/ui/conversation/v2/composer/model/ConversationSendActionButtonMode.kt create mode 100644 src/com/android/messaging/ui/conversation/v2/mediapicker/model/ConversationMediaPickerPermissionState.kt create mode 100644 src/com/android/messaging/ui/conversation/v2/messages/model/text/ConversationTextLink.kt diff --git a/src/com/android/messaging/ui/conversation/v2/composer/model/ConversationSendActionButtonGestureState.kt b/src/com/android/messaging/ui/conversation/v2/composer/model/ConversationSendActionButtonGestureState.kt new file mode 100644 index 00000000..47fca3fd --- /dev/null +++ b/src/com/android/messaging/ui/conversation/v2/composer/model/ConversationSendActionButtonGestureState.kt @@ -0,0 +1,9 @@ +package com.android.messaging.ui.conversation.v2.composer.model + +import androidx.compose.runtime.Immutable + +@Immutable +internal data class ConversationSendActionButtonGestureState( + val cancelDragDistancePx: Float = 0f, + val lockDragDistancePx: Float = 0f, +) diff --git a/src/com/android/messaging/ui/conversation/v2/composer/model/ConversationSendActionButtonMode.kt b/src/com/android/messaging/ui/conversation/v2/composer/model/ConversationSendActionButtonMode.kt new file mode 100644 index 00000000..9779cdf0 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/v2/composer/model/ConversationSendActionButtonMode.kt @@ -0,0 +1,10 @@ +package com.android.messaging.ui.conversation.v2.composer.model + +import androidx.compose.runtime.Immutable + +@Immutable +internal enum class ConversationSendActionButtonMode { + Send, + Record, + Stop, +} diff --git a/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationComposeBar.kt b/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationComposeBar.kt index 8362a30f..32b45fc9 100644 --- a/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationComposeBar.kt +++ b/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationComposeBar.kt @@ -39,6 +39,8 @@ import com.android.messaging.ui.conversation.v2.CONVERSATION_SEND_BUTTON_SHAPE_C import com.android.messaging.ui.conversation.v2.CONVERSATION_SEND_BUTTON_TEST_TAG import com.android.messaging.ui.conversation.v2.audio.model.ConversationAudioRecordingPhase import com.android.messaging.ui.conversation.v2.audio.model.ConversationAudioRecordingUiState +import com.android.messaging.ui.conversation.v2.composer.model.ConversationSendActionButtonGestureState +import com.android.messaging.ui.conversation.v2.composer.model.ConversationSendActionButtonMode import com.android.messaging.ui.conversation.v2.conversationShape internal val AUDIO_RECORD_CANCEL_THRESHOLD = 96.dp diff --git a/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationSendActionButton.kt b/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationSendActionButton.kt index ac611a68..93911f45 100644 --- a/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationSendActionButton.kt +++ b/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationSendActionButton.kt @@ -46,13 +46,8 @@ import androidx.compose.ui.semantics.onClick import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp import com.android.messaging.R - -@Immutable -internal enum class ConversationSendActionButtonMode { - Send, - Record, - Stop, -} +import com.android.messaging.ui.conversation.v2.composer.model.ConversationSendActionButtonGestureState +import com.android.messaging.ui.conversation.v2.composer.model.ConversationSendActionButtonMode @Immutable private data class ConversationSendActionButtonVisualState( diff --git a/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationSendActionButtonGesture.kt b/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationSendActionButtonGesture.kt index 5c56da38..116cc1b1 100644 --- a/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationSendActionButtonGesture.kt +++ b/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationSendActionButtonGesture.kt @@ -4,7 +4,6 @@ import androidx.compose.foundation.gestures.awaitEachGesture import androidx.compose.foundation.gestures.awaitFirstDown import androidx.compose.foundation.gestures.awaitLongPressOrCancellation import androidx.compose.runtime.Composable -import androidx.compose.runtime.Immutable import androidx.compose.runtime.getValue import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Modifier @@ -12,12 +11,8 @@ import androidx.compose.ui.input.pointer.AwaitPointerEventScope import androidx.compose.ui.input.pointer.PointerId import androidx.compose.ui.input.pointer.PointerInputChange import androidx.compose.ui.input.pointer.pointerInput - -@Immutable -internal data class ConversationSendActionButtonGestureState( - val cancelDragDistancePx: Float = 0f, - val lockDragDistancePx: Float = 0f, -) +import com.android.messaging.ui.conversation.v2.composer.model.ConversationSendActionButtonGestureState +import com.android.messaging.ui.conversation.v2.composer.model.ConversationSendActionButtonMode @Composable internal fun Modifier.conversationSendActionButtonGesture( diff --git a/src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationMediaPickerPermission.kt b/src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationMediaPickerPermission.kt index ebc54f13..161ac172 100644 --- a/src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationMediaPickerPermission.kt +++ b/src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationMediaPickerPermission.kt @@ -1,35 +1,15 @@ package com.android.messaging.ui.conversation.v2.mediapicker -import android.Manifest -import android.content.Context -import android.content.pm.PackageManager import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.Stable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.focus.FocusManager import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.SoftwareKeyboardController -import androidx.core.content.ContextCompat import androidx.lifecycle.Lifecycle import androidx.lifecycle.compose.LifecycleEventEffect - -@Stable -internal class ConversationMediaPickerPermissionState( - context: Context, -) { - var audioPermissionGranted by mutableStateOf(value = hasAudioPermission(context = context)) - var cameraPermissionGranted by mutableStateOf(value = hasCameraPermission(context = context)) - - fun refresh(context: Context) { - audioPermissionGranted = hasAudioPermission(context = context) - cameraPermissionGranted = hasCameraPermission(context = context) - } -} +import com.android.messaging.ui.conversation.v2.mediapicker.model.ConversationMediaPickerPermissionState @Composable internal fun rememberConversationMediaPickerPermissionState(): @@ -79,27 +59,3 @@ internal fun HandleConversationMediaPickerVisibilityEffect( state.shouldRestoreKeyboard = false } } - -private fun hasCameraPermission(context: Context): Boolean { - return isPermissionGranted( - context = context, - permission = Manifest.permission.CAMERA, - ) -} - -private fun hasAudioPermission(context: Context): Boolean { - return isPermissionGranted( - context = context, - permission = Manifest.permission.RECORD_AUDIO, - ) -} - -private fun isPermissionGranted( - context: Context, - permission: String, -): Boolean { - return ContextCompat.checkSelfPermission( - context, - permission, - ) == PackageManager.PERMISSION_GRANTED -} diff --git a/src/com/android/messaging/ui/conversation/v2/mediapicker/component/review/ConversationMediaPickerReview.kt b/src/com/android/messaging/ui/conversation/v2/mediapicker/component/review/ConversationMediaPickerReview.kt index 707c254a..28257b1f 100644 --- a/src/com/android/messaging/ui/conversation/v2/mediapicker/component/review/ConversationMediaPickerReview.kt +++ b/src/com/android/messaging/ui/conversation/v2/mediapicker/component/review/ConversationMediaPickerReview.kt @@ -45,8 +45,8 @@ import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import com.android.messaging.R import com.android.messaging.ui.conversation.v2.composer.model.ComposerAttachmentUiModel +import com.android.messaging.ui.conversation.v2.composer.model.ConversationSendActionButtonMode import com.android.messaging.ui.conversation.v2.composer.ui.ConversationSendActionButton -import com.android.messaging.ui.conversation.v2.composer.ui.ConversationSendActionButtonMode import com.android.messaging.ui.conversation.v2.mediapicker.component.PickerOverlayIconButton import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableMap diff --git a/src/com/android/messaging/ui/conversation/v2/mediapicker/model/ConversationMediaPickerPermissionState.kt b/src/com/android/messaging/ui/conversation/v2/mediapicker/model/ConversationMediaPickerPermissionState.kt new file mode 100644 index 00000000..5081bdc9 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/v2/mediapicker/model/ConversationMediaPickerPermissionState.kt @@ -0,0 +1,47 @@ +package com.android.messaging.ui.conversation.v2.mediapicker.model + +import android.Manifest +import android.content.Context +import android.content.pm.PackageManager +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.core.content.ContextCompat + +@Stable +internal class ConversationMediaPickerPermissionState( + context: Context, +) { + var audioPermissionGranted by mutableStateOf(value = hasAudioPermission(context = context)) + var cameraPermissionGranted by mutableStateOf(value = hasCameraPermission(context = context)) + + fun refresh(context: Context) { + audioPermissionGranted = hasAudioPermission(context = context) + cameraPermissionGranted = hasCameraPermission(context = context) + } +} + +private fun hasCameraPermission(context: Context): Boolean { + return isPermissionGranted( + context = context, + permission = Manifest.permission.CAMERA, + ) +} + +private fun hasAudioPermission(context: Context): Boolean { + return isPermissionGranted( + context = context, + permission = Manifest.permission.RECORD_AUDIO, + ) +} + +private fun isPermissionGranted( + context: Context, + permission: String, +): Boolean { + return ContextCompat.checkSelfPermission( + context, + permission, + ) == PackageManager.PERMISSION_GRANTED +} diff --git a/src/com/android/messaging/ui/conversation/v2/messages/model/text/ConversationTextLink.kt b/src/com/android/messaging/ui/conversation/v2/messages/model/text/ConversationTextLink.kt new file mode 100644 index 00000000..2c79d721 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/v2/messages/model/text/ConversationTextLink.kt @@ -0,0 +1,10 @@ +package com.android.messaging.ui.conversation.v2.messages.model.text + +import androidx.compose.runtime.Immutable + +@Immutable +internal data class ConversationTextLink( + val start: Int, + val end: Int, + val url: String, +) diff --git a/src/com/android/messaging/ui/conversation/v2/messages/ui/text/ConversationMessageText.kt b/src/com/android/messaging/ui/conversation/v2/messages/ui/text/ConversationMessageText.kt index 1e24aab2..751d4578 100644 --- a/src/com/android/messaging/ui/conversation/v2/messages/ui/text/ConversationMessageText.kt +++ b/src/com/android/messaging/ui/conversation/v2/messages/ui/text/ConversationMessageText.kt @@ -16,6 +16,7 @@ import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.withLink +import com.android.messaging.ui.conversation.v2.messages.model.text.ConversationTextLink import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext diff --git a/src/com/android/messaging/ui/conversation/v2/messages/ui/text/ConversationMessageTextLinkExtractor.kt b/src/com/android/messaging/ui/conversation/v2/messages/ui/text/ConversationMessageTextLinkExtractor.kt index 645aff1a..3b969c67 100644 --- a/src/com/android/messaging/ui/conversation/v2/messages/ui/text/ConversationMessageTextLinkExtractor.kt +++ b/src/com/android/messaging/ui/conversation/v2/messages/ui/text/ConversationMessageTextLinkExtractor.kt @@ -6,14 +6,7 @@ import android.view.textclassifier.TextClassificationManager import android.view.textclassifier.TextClassifier import android.view.textclassifier.TextLinks import android.webkit.URLUtil -import androidx.compose.runtime.Immutable - -@Immutable -internal data class ConversationTextLink( - val start: Int, - val end: Int, - val url: String, -) +import com.android.messaging.ui.conversation.v2.messages.model.text.ConversationTextLink private data class ConversationLinkText( val start: Int, diff --git a/src/com/android/messaging/ui/conversation/v2/screen/ConversationScreenRoute.kt b/src/com/android/messaging/ui/conversation/v2/screen/ConversationScreenRoute.kt index b10a9ec9..b1bdae2c 100644 --- a/src/com/android/messaging/ui/conversation/v2/screen/ConversationScreenRoute.kt +++ b/src/com/android/messaging/ui/conversation/v2/screen/ConversationScreenRoute.kt @@ -26,9 +26,9 @@ import com.android.messaging.data.conversation.model.draft.ConversationDraft import com.android.messaging.ui.conversation.v2.audio.model.ConversationAudioRecordingPhase import com.android.messaging.ui.conversation.v2.entry.model.ConversationEntryStartupAttachment import com.android.messaging.ui.conversation.v2.mediapicker.ConversationMediaPickerOverlay -import com.android.messaging.ui.conversation.v2.mediapicker.ConversationMediaPickerPermissionState import com.android.messaging.ui.conversation.v2.mediapicker.ConversationMediaPickerState import com.android.messaging.ui.conversation.v2.mediapicker.RefreshConversationMediaPickerPermissionsEffect +import com.android.messaging.ui.conversation.v2.mediapicker.model.ConversationMediaPickerPermissionState import com.android.messaging.ui.conversation.v2.screen.model.ConversationMediaPickerOverlayUiState import com.android.messaging.ui.conversation.v2.screen.model.ConversationScreenScaffoldUiState From 7b0f3f7ff1e66fbd929da2c8b59859303b0734d5 Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Thu, 30 Apr 2026 20:21:24 +0300 Subject: [PATCH 87/99] Fix LoopWithTooManyJumpStatements errors --- .../ConversationParticipantsRepository.kt | 56 ++--- .../ConversationRecipientsRepository.kt | 4 +- .../ui/ConversationSendActionButtonGesture.kt | 221 ++++++++++-------- 3 files changed, 158 insertions(+), 123 deletions(-) diff --git a/src/com/android/messaging/data/conversation/repository/ConversationParticipantsRepository.kt b/src/com/android/messaging/data/conversation/repository/ConversationParticipantsRepository.kt index 401e93c5..97ef9dc1 100644 --- a/src/com/android/messaging/data/conversation/repository/ConversationParticipantsRepository.kt +++ b/src/com/android/messaging/data/conversation/repository/ConversationParticipantsRepository.kt @@ -77,38 +77,42 @@ internal class ConversationParticipantsRepositoryImpl @Inject constructor( while (cursor.moveToNext()) { val participant = ParticipantData.getFromCursor(cursor) + val recipient = mapParticipant(participant = participant) - if (participant.isSelf) { - continue + if (recipient != null && seenDestinations.add(recipient.destination)) { + participants.add(recipient) } - - val destination = participant.sendDestination - ?.trim() - .orEmpty() - - if (destination.isBlank()) { - continue - } - - if (!seenDestinations.add(destination)) { - continue - } - - participants.add( - ConversationRecipient( - id = participant.id, - displayName = participant.getDisplayName(true), - destination = destination, - photoUri = participant.profilePhotoUri, - secondaryText = participant.displayDestination - ?.takeIf { it.isNotBlank() } - ?.takeIf { it != participant.getDisplayName(true) }, - ), - ) } participants.build() } ?: persistentListOf() } + + private fun mapParticipant(participant: ParticipantData): ConversationRecipient? { + val destination = participantDestination(participant = participant) ?: return null + val displayName = participant.getDisplayName(true) + + return ConversationRecipient( + id = participant.id, + displayName = displayName, + destination = destination, + photoUri = participant.profilePhotoUri, + secondaryText = participant.displayDestination + ?.takeIf { it.isNotBlank() } + ?.takeIf { it != displayName }, + ) + } + + private fun participantDestination(participant: ParticipantData): String? { + return when { + participant.isSelf -> null + + else -> { + participant.sendDestination + ?.trim() + ?.takeIf { it.isNotBlank() } + } + } + } } diff --git a/src/com/android/messaging/data/conversation/repository/ConversationRecipientsRepository.kt b/src/com/android/messaging/data/conversation/repository/ConversationRecipientsRepository.kt index 591a0269..78e7f5cf 100644 --- a/src/com/android/messaging/data/conversation/repository/ConversationRecipientsRepository.kt +++ b/src/com/android/messaging/data/conversation/repository/ConversationRecipientsRepository.kt @@ -188,9 +188,9 @@ internal class ConversationRecipientsRepositoryImpl @Inject constructor( val recipientEntry = mapRecipientEntry( cursor = cursor, recipientCursorColumns = recipientCursorColumns, - ) ?: continue + ) - if (!matchesRecipient(recipientEntry.recipient)) { + if (recipientEntry == null || !matchesRecipient(recipientEntry.recipient)) { continue } diff --git a/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationSendActionButtonGesture.kt b/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationSendActionButtonGesture.kt index 116cc1b1..0a43fd33 100644 --- a/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationSendActionButtonGesture.kt +++ b/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationSendActionButtonGesture.kt @@ -38,43 +38,36 @@ internal fun Modifier.conversationSendActionButtonGesture( val currentOnRecordGestureFinish by rememberUpdatedState(newValue = onRecordGestureFinish) val currentOnLockedStopClick by rememberUpdatedState(newValue = onLockedStopClick) - return when { - mode != ConversationSendActionButtonMode.Send && enabled -> { - pointerInput( - mode, - enabled, - cancelThresholdPx, - lockThresholdPx, - ) { - awaitEachGesture { - when { - currentIsRecordingActive && currentIsRecordingLocked -> { - handleLockedRecordGesture( - cancelThresholdPx = cancelThresholdPx, - onGestureActiveChange = currentOnGestureActiveChange, - onRecordGestureMove = currentOnRecordGestureMove, - onRecordGestureFinish = currentOnRecordGestureFinish, - onLockedStopClick = currentOnLockedStopClick, - ) - } - - else -> { - handleRecordGesture( - cancelThresholdPx = cancelThresholdPx, - lockThresholdPx = lockThresholdPx, - onGestureActiveChange = currentOnGestureActiveChange, - onRecordGestureStart = currentOnRecordGestureStart, - onRecordGestureMove = currentOnRecordGestureMove, - onRecordGestureLock = currentOnRecordGestureLock, - onRecordGestureFinish = currentOnRecordGestureFinish, - ) - } - } - } + if (mode == ConversationSendActionButtonMode.Send || !enabled) { + return this + } + + return pointerInput( + mode, + cancelThresholdPx, + lockThresholdPx, + ) { + awaitEachGesture { + if (currentIsRecordingActive && currentIsRecordingLocked) { + handleLockedRecordGesture( + cancelThresholdPx = cancelThresholdPx, + onGestureActiveChange = currentOnGestureActiveChange, + onRecordGestureMove = currentOnRecordGestureMove, + onRecordGestureFinish = currentOnRecordGestureFinish, + onLockedStopClick = currentOnLockedStopClick, + ) + } else { + handleRecordGesture( + cancelThresholdPx = cancelThresholdPx, + lockThresholdPx = lockThresholdPx, + onGestureActiveChange = currentOnGestureActiveChange, + onRecordGestureStart = currentOnRecordGestureStart, + onRecordGestureMove = currentOnRecordGestureMove, + onRecordGestureLock = currentOnRecordGestureLock, + onRecordGestureFinish = currentOnRecordGestureFinish, + ) } } - - else -> this } } @@ -121,47 +114,48 @@ private suspend fun AwaitPointerEventScope.trackRecordGestureDrag( longPressChange.consume() - while (true) { - val pointerChange = awaitRecordGestureChange(pointerId = initialDown.id) ?: break - val gestureState = calculateRecordGestureState( - initialDown = initialDown, - pointerChange = pointerChange, + val releaseGestureState = awaitRecordGestureRelease(initialDown = initialDown) { gestureState -> + isRecordingLocked = updateRecordGestureLockState( + gestureState = gestureState, + isRecordingLocked = isRecordingLocked, + lockThresholdPx = lockThresholdPx, + onRecordGestureMove = onRecordGestureMove, + onRecordGestureLock = onRecordGestureLock, ) + } - if (!isRecordingLocked) { - onRecordGestureMove(gestureState) - - if (gestureState.lockDragDistancePx >= lockThresholdPx) { - isRecordingLocked = onRecordGestureLock() + resetRecordGestureDragUi( + onGestureActiveChange = onGestureActiveChange, + onRecordGestureMove = onRecordGestureMove, + ) - if (isRecordingLocked) { - onRecordGestureMove(ConversationSendActionButtonGestureState()) - } - } - } + if (releaseGestureState != null && !isRecordingLocked) { + onRecordGestureFinish(releaseGestureState.cancelDragDistancePx >= cancelThresholdPx) + } +} - pointerChange.consume() +private fun updateRecordGestureLockState( + gestureState: ConversationSendActionButtonGestureState, + isRecordingLocked: Boolean, + lockThresholdPx: Float, + onRecordGestureMove: (ConversationSendActionButtonGestureState) -> Unit, + onRecordGestureLock: () -> Boolean, +): Boolean { + var updatedIsRecordingLocked = isRecordingLocked - if (pointerChange.pressed) { - continue - } + if (!updatedIsRecordingLocked) { + onRecordGestureMove(gestureState) - resetRecordGestureDragUi( - onGestureActiveChange = onGestureActiveChange, - onRecordGestureMove = onRecordGestureMove, - ) + if (gestureState.lockDragDistancePx >= lockThresholdPx) { + updatedIsRecordingLocked = onRecordGestureLock() - if (!isRecordingLocked) { - onRecordGestureFinish(gestureState.cancelDragDistancePx >= cancelThresholdPx) + if (updatedIsRecordingLocked) { + onRecordGestureMove(ConversationSendActionButtonGestureState()) + } } - - return } - resetRecordGestureDragUi( - onGestureActiveChange = onGestureActiveChange, - onRecordGestureMove = onRecordGestureMove, - ) + return updatedIsRecordingLocked } private suspend fun AwaitPointerEventScope.handleLockedRecordGesture( @@ -176,42 +170,44 @@ private suspend fun AwaitPointerEventScope.handleLockedRecordGesture( onGestureActiveChange(true) initialDown.consume() - while (true) { - val pointerChange = awaitRecordGestureChange(pointerId = initialDown.id) ?: break - val gestureState = calculateRecordGestureState( - initialDown = initialDown, - pointerChange = pointerChange, - ) - + val releaseGestureState = awaitRecordGestureRelease(initialDown = initialDown) { gestureState -> onRecordGestureMove( ConversationSendActionButtonGestureState( cancelDragDistancePx = gestureState.cancelDragDistancePx, ), ) - pointerChange.consume() - - if (!pointerChange.pressed) { - resetRecordGestureDragUi( - onGestureActiveChange = onGestureActiveChange, - onRecordGestureMove = onRecordGestureMove, - ) - when { - gestureState.cancelDragDistancePx >= cancelThresholdPx -> { - onRecordGestureFinish(true) - } - - else -> { - onLockedStopClick() - } - } - return - } } resetRecordGestureDragUi( onGestureActiveChange = onGestureActiveChange, onRecordGestureMove = onRecordGestureMove, ) + + if (releaseGestureState != null) { + handleLockedRecordGestureRelease( + gestureState = releaseGestureState, + cancelThresholdPx = cancelThresholdPx, + onRecordGestureFinish = onRecordGestureFinish, + onLockedStopClick = onLockedStopClick, + ) + } +} + +private fun handleLockedRecordGestureRelease( + gestureState: ConversationSendActionButtonGestureState, + cancelThresholdPx: Float, + onRecordGestureFinish: (Boolean) -> Unit, + onLockedStopClick: () -> Unit, +) { + when { + gestureState.cancelDragDistancePx >= cancelThresholdPx -> { + onRecordGestureFinish(true) + } + + else -> { + onLockedStopClick() + } + } } private fun resetRecordGestureDragUi( @@ -222,6 +218,37 @@ private fun resetRecordGestureDragUi( onRecordGestureMove(ConversationSendActionButtonGestureState()) } +private suspend fun AwaitPointerEventScope.awaitRecordGestureRelease( + initialDown: PointerInputChange, + onGestureChange: (ConversationSendActionButtonGestureState) -> Unit, +): ConversationSendActionButtonGestureState? { + var releaseGestureState: ConversationSendActionButtonGestureState? = null + var isTrackingGesture = true + + while (isTrackingGesture) { + val pointerChange = awaitRecordGestureChange(pointerId = initialDown.id) + + if (pointerChange == null) { + isTrackingGesture = false + } else { + val gestureState = calculateRecordGestureState( + initialDown = initialDown, + pointerChange = pointerChange, + ) + + onGestureChange(gestureState) + pointerChange.consume() + + if (!pointerChange.pressed) { + releaseGestureState = gestureState + isTrackingGesture = false + } + } + } + + return releaseGestureState +} + private suspend fun AwaitPointerEventScope.awaitRecordGestureChange( pointerId: PointerId, ): PointerInputChange? { @@ -236,10 +263,14 @@ private fun calculateRecordGestureState( initialDown: PointerInputChange, pointerChange: PointerInputChange, ): ConversationSendActionButtonGestureState { + val cancelDragDistancePx = (initialDown.position.x - pointerChange.position.x) + .coerceAtLeast(minimumValue = 0f) + + val lockDragDistancePx = (initialDown.position.y - pointerChange.position.y) + .coerceAtLeast(minimumValue = 0f) + return ConversationSendActionButtonGestureState( - cancelDragDistancePx = (initialDown.position.x - pointerChange.position.x) - .coerceAtLeast(minimumValue = 0f), - lockDragDistancePx = (initialDown.position.y - pointerChange.position.y) - .coerceAtLeast(minimumValue = 0f), + cancelDragDistancePx = cancelDragDistancePx, + lockDragDistancePx = lockDragDistancePx, ) } From 12c8006f4416b5ade1982e1d88ca0d9e2171195b Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Thu, 30 Apr 2026 20:25:22 +0300 Subject: [PATCH 88/99] Suppress false-positive CyclomaticComplexMethod errors --- .../v2/messages/mapper/ConversationMessageUiModelMapper.kt | 1 + .../conversation/v2/messages/ui/message/ConversationMessage.kt | 1 + 2 files changed, 2 insertions(+) diff --git a/src/com/android/messaging/ui/conversation/v2/messages/mapper/ConversationMessageUiModelMapper.kt b/src/com/android/messaging/ui/conversation/v2/messages/mapper/ConversationMessageUiModelMapper.kt index 330f3f33..4419823e 100644 --- a/src/com/android/messaging/ui/conversation/v2/messages/mapper/ConversationMessageUiModelMapper.kt +++ b/src/com/android/messaging/ui/conversation/v2/messages/mapper/ConversationMessageUiModelMapper.kt @@ -127,6 +127,7 @@ internal class ConversationMessageUiModelMapperImpl @Inject constructor( } } + @Suppress("CyclomaticComplexMethod") private fun mapStatus(javaStatus: Int): Status { return when (javaStatus) { MessageData.BUGLE_STATUS_UNKNOWN -> Status.Unknown diff --git a/src/com/android/messaging/ui/conversation/v2/messages/ui/message/ConversationMessage.kt b/src/com/android/messaging/ui/conversation/v2/messages/ui/message/ConversationMessage.kt index bd1a0fb1..e4e05814 100644 --- a/src/com/android/messaging/ui/conversation/v2/messages/ui/message/ConversationMessage.kt +++ b/src/com/android/messaging/ui/conversation/v2/messages/ui/message/ConversationMessage.kt @@ -423,6 +423,7 @@ private fun buildMessageMetadataText( return "$formattedTime \u2022 $statusText" } +@Suppress("CyclomaticComplexMethod") private fun messageStatusTextResourceId(status: Status): Int? { return when (status) { Status.Outgoing.Delivered -> R.string.delivered_status_content_description From a8e196a10a2ca181b7e1bdc04b5866ca4f193854 Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Thu, 30 Apr 2026 23:18:02 +0300 Subject: [PATCH 89/99] Fix ReturnCount detekt errors --- .../ConversationRecipientsRepository.kt | 35 ++- .../ConversationSubscriptionsRepository.kt | 28 ++- .../repository/ConversationsRepository.kt | 29 ++- .../ConversationAudioRecordingDelegate.kt | 104 ++++----- .../delegate/ConversationDraftEditorState.kt | 207 ++++++++++++------ .../v2/entry/ConversationEntryViewModel.kt | 59 ++--- .../camera/ConversationCameraController.kt | 57 ++--- .../ConversationMessageSelectionDelegate.kt | 156 ++++++------- .../v2/messages/ui/ConversationMessages.kt | 18 +- ...ationInlineAudioAttachmentPlaybackState.kt | 43 ++-- .../ui/message/ConversationMessage.kt | 50 +++-- .../ui/message/ConversationMessageBubble.kt | 41 ---- .../ConversationMessageContentBuilder.kt | 13 +- .../ui/message/ConversationMessageMetadata.kt | 50 +++++ .../v2/navigation/ConversationNavGraph.kt | 12 +- .../v2/screen/ConversationScreenEffects.kt | 10 +- .../v2/screen/ConversationViewModel.kt | 40 ++-- 17 files changed, 534 insertions(+), 418 deletions(-) create mode 100644 src/com/android/messaging/ui/conversation/v2/messages/ui/message/ConversationMessageMetadata.kt diff --git a/src/com/android/messaging/data/conversation/repository/ConversationRecipientsRepository.kt b/src/com/android/messaging/data/conversation/repository/ConversationRecipientsRepository.kt index 78e7f5cf..348a5b6c 100644 --- a/src/com/android/messaging/data/conversation/repository/ConversationRecipientsRepository.kt +++ b/src/com/android/messaging/data/conversation/repository/ConversationRecipientsRepository.kt @@ -280,26 +280,24 @@ internal class ConversationRecipientsRepositoryImpl @Inject constructor( recipients: List, offset: Int, ): ConversationRecipientsPage { - if (offset >= recipients.size) { - return emptyRecipientsPage() - } - - val pagedRecipients = persistentListOf().builder() + val pageStart = offset.coerceAtMost(maximumValue = recipients.size) + val pageEndExclusive = (pageStart + PAGE_SIZE).coerceAtMost(maximumValue = recipients.size) - for (index in offset until recipients.size) { - if (pagedRecipients.size == PAGE_SIZE) { - return ConversationRecipientsPage( - recipients = pagedRecipients.build(), - nextOffset = index, - ) - } + val pagedRecipients = recipients + .subList( + fromIndex = pageStart, + toIndex = pageEndExclusive, + ) + .map { it.recipient } + .toPersistentList() - pagedRecipients.add(recipients[index].recipient) + val nextOffset = pageEndExclusive.takeIf { nextOffset -> + nextOffset < recipients.size } return ConversationRecipientsPage( - recipients = pagedRecipients.build(), - nextOffset = null, + recipients = pagedRecipients, + nextOffset = nextOffset, ) } @@ -320,13 +318,6 @@ internal class ConversationRecipientsRepositoryImpl @Inject constructor( return value.filter { character -> character.isDigit() } } - private fun emptyRecipientsPage(): ConversationRecipientsPage { - return ConversationRecipientsPage( - recipients = persistentListOf(), - nextOffset = null, - ) - } - private companion object { private const val PAGE_SIZE = 200 diff --git a/src/com/android/messaging/data/conversation/repository/ConversationSubscriptionsRepository.kt b/src/com/android/messaging/data/conversation/repository/ConversationSubscriptionsRepository.kt index 219e131d..c94cf87f 100644 --- a/src/com/android/messaging/data/conversation/repository/ConversationSubscriptionsRepository.kt +++ b/src/com/android/messaging/data/conversation/repository/ConversationSubscriptionsRepository.kt @@ -200,11 +200,25 @@ internal class ConversationSubscriptionsRepositoryImpl @Inject constructor( private fun queryMaxMessageSize( selfParticipantId: String, ): Int { + val resolvedSubId = resolveSubscriptionId(selfParticipantId) + + return when { + resolvedSubId == null || resolvedSubId <= ParticipantData.DEFAULT_SELF_SUB_ID -> { + MmsConfig.getMaxMaxMessageSize() + } + + else -> { + MmsConfig.get(resolvedSubId).maxMessageSize + } + } + } + + private fun resolveSubscriptionId(selfParticipantId: String): Int? { if (selfParticipantId.isBlank()) { - return MmsConfig.getMaxMaxMessageSize() + return null } - val resolvedSubId = contentResolver.query( + return contentResolver.query( MessagingContentProvider.PARTICIPANTS_URI, ParticipantData.ParticipantsQuery.PROJECTION, "${ParticipantColumns._ID} = ?", @@ -212,16 +226,12 @@ internal class ConversationSubscriptionsRepositoryImpl @Inject constructor( null, )?.use { cursor -> when { - cursor.moveToFirst() -> ParticipantData.getFromCursor(cursor).subId + cursor.moveToFirst() -> { + ParticipantData.getFromCursor(cursor).subId + } else -> null } - } ?: return MmsConfig.getMaxMaxMessageSize() - - if (resolvedSubId <= ParticipantData.DEFAULT_SELF_SUB_ID) { - return MmsConfig.getMaxMaxMessageSize() } - - return MmsConfig.get(resolvedSubId).maxMessageSize } private companion object { diff --git a/src/com/android/messaging/data/conversation/repository/ConversationsRepository.kt b/src/com/android/messaging/data/conversation/repository/ConversationsRepository.kt index 838a40ed..98fbd2ad 100644 --- a/src/com/android/messaging/data/conversation/repository/ConversationsRepository.kt +++ b/src/com/android/messaging/data/conversation/repository/ConversationsRepository.kt @@ -96,21 +96,26 @@ internal class ConversationsRepositoryImpl @Inject constructor( conversationId: String, requestedSelfParticipantId: String, ): ConversationSendData? { - if (conversationId.isBlank()) { - return null + val metadata = when { + conversationId.isBlank() -> null + else -> { + MessagingContentProvider + .buildConversationMetadataUri(conversationId) + .let(::queryConversationMetadata) + } } - val uri = MessagingContentProvider.buildConversationMetadataUri(conversationId) - val metadata = queryConversationMetadata(uri = uri) ?: return null - val resolvedSelfParticipantId = requestedSelfParticipantId - .takeIf { it.isNotBlank() } - ?: metadata.selfParticipantId + return metadata?.let { conversationMetadata -> + val resolvedSelfParticipantId = requestedSelfParticipantId + .takeIf { it.isNotBlank() } + ?: conversationMetadata.selfParticipantId - return ConversationSendData( - metadata = metadata, - participants = queryConversationParticipants(conversationId = conversationId), - selfParticipant = queryParticipant(participantId = resolvedSelfParticipantId), - ) + ConversationSendData( + metadata = conversationMetadata, + participants = queryConversationParticipants(conversationId = conversationId), + selfParticipant = queryParticipant(participantId = resolvedSelfParticipantId), + ) + } } override fun getConversationMessage( diff --git a/src/com/android/messaging/ui/conversation/v2/audio/delegate/ConversationAudioRecordingDelegate.kt b/src/com/android/messaging/ui/conversation/v2/audio/delegate/ConversationAudioRecordingDelegate.kt index a3b219c9..800fbd39 100644 --- a/src/com/android/messaging/ui/conversation/v2/audio/delegate/ConversationAudioRecordingDelegate.kt +++ b/src/com/android/messaging/ui/conversation/v2/audio/delegate/ConversationAudioRecordingDelegate.kt @@ -414,34 +414,41 @@ internal class ConversationAudioRecordingDelegateImpl @Inject constructor( durationJob: Job?, ): AudioRecordingEffect { val currentSessionState = sessionState as? AudioRecordingSessionState.Starting - ?: return AudioRecordingEffect.StopAndDeleteRecording( - mediaRecorder = mediaRecorder, - durationJob = durationJob, - ) - if (currentSessionState.queuedIntent == QueuedStartIntent.Cancel) { - sessionState = AudioRecordingSessionState.Idle - publishUiStateLocked() + return when { + currentSessionState == null -> { + AudioRecordingEffect.StopAndDeleteRecording( + mediaRecorder = mediaRecorder, + durationJob = durationJob, + ) + } - return AudioRecordingEffect.StopAndDeleteRecording( - mediaRecorder = mediaRecorder, - durationJob = durationJob, - ) - } + currentSessionState.queuedIntent == QueuedStartIntent.Cancel -> { + sessionState = AudioRecordingSessionState.Idle + publishUiStateLocked() - sessionState = AudioRecordingSessionState.Recording( - mediaRecorder = mediaRecorder, - startedAtMillis = startedAtMillis, - durationMillis = 0L, - isLocked = currentSessionState.queuedIntent == QueuedStartIntent.Lock, - durationJob = requireNotNull(durationJob) { - "Duration job must be available for active recording" - }, - ) + AudioRecordingEffect.StopAndDeleteRecording( + mediaRecorder = mediaRecorder, + durationJob = durationJob, + ) + } - publishUiStateLocked() + else -> { + sessionState = AudioRecordingSessionState.Recording( + mediaRecorder = mediaRecorder, + startedAtMillis = startedAtMillis, + durationMillis = 0L, + isLocked = currentSessionState.queuedIntent == QueuedStartIntent.Lock, + durationJob = requireNotNull(durationJob) { + "Duration job must be available for active recording" + }, + ) - return AudioRecordingEffect.None + publishUiStateLocked() + + AudioRecordingEffect.None + } + } } private suspend fun finalizeRecording(pendingAttachmentId: String) { @@ -482,15 +489,14 @@ internal class ConversationAudioRecordingDelegateImpl @Inject constructor( pendingAttachmentId: String, ): LevelTrackingMediaRecorder? { val currentSessionState = sessionState as? AudioRecordingSessionState.Finalizing - ?: return null + var claimedMediaRecorder: LevelTrackingMediaRecorder? = null - if (currentSessionState.pendingAttachmentId != pendingAttachmentId) { - return null + if (currentSessionState?.pendingAttachmentId == pendingAttachmentId) { + sessionState = currentSessionState.copy(mediaRecorder = null) + claimedMediaRecorder = currentSessionState.mediaRecorder } - sessionState = currentSessionState.copy(mediaRecorder = null) - - return currentSessionState.mediaRecorder + return claimedMediaRecorder } private fun storeStoppedRecordingUriLocked( @@ -498,27 +504,23 @@ internal class ConversationAudioRecordingDelegateImpl @Inject constructor( outputUri: Uri?, ): Boolean { val currentSessionState = sessionState as? AudioRecordingSessionState.Finalizing - ?: return false + var didStoreStoppedRecordingUri = false - if (currentSessionState.pendingAttachmentId != pendingAttachmentId) { - return false + if (currentSessionState?.pendingAttachmentId == pendingAttachmentId) { + sessionState = currentSessionState.copy(stoppedRecordingUri = outputUri) + didStoreStoppedRecordingUri = true } - sessionState = currentSessionState.copy(stoppedRecordingUri = outputUri) - - return true + return didStoreStoppedRecordingUri } private fun clearFinalizingSessionLocked(pendingAttachmentId: String) { val currentSessionState = sessionState as? AudioRecordingSessionState.Finalizing - ?: return - if (currentSessionState.pendingAttachmentId != pendingAttachmentId) { - return + if (currentSessionState?.pendingAttachmentId == pendingAttachmentId) { + sessionState = AudioRecordingSessionState.Idle + publishUiStateLocked() } - - sessionState = AudioRecordingSessionState.Idle - publishUiStateLocked() } private fun addPendingAudioAttachment(pendingAttachmentId: String) { @@ -577,18 +579,20 @@ internal class ConversationAudioRecordingDelegateImpl @Inject constructor( private fun tickRecordingDurationLocked(startedAtMillis: Long): Boolean { val currentSessionState = sessionState as? AudioRecordingSessionState.Recording - ?: return false + var shouldContinueTicking = false - if (currentSessionState.startedAtMillis != startedAtMillis) { - return false - } + val isMatchingRecordingSession = currentSessionState?.startedAtMillis == startedAtMillis - sessionState = currentSessionState.copy( - durationMillis = SystemClock.elapsedRealtime() - startedAtMillis, - ) - publishUiStateLocked() + if (isMatchingRecordingSession) { + sessionState = currentSessionState.copy( + durationMillis = SystemClock.elapsedRealtime() - startedAtMillis, + ) - return true + publishUiStateLocked() + shouldContinueTicking = true + } + + return shouldContinueTicking } @Suppress("TooGenericExceptionCaught") diff --git a/src/com/android/messaging/ui/conversation/v2/composer/delegate/ConversationDraftEditorState.kt b/src/com/android/messaging/ui/conversation/v2/composer/delegate/ConversationDraftEditorState.kt index 1c99d20f..bc33fb93 100644 --- a/src/com/android/messaging/ui/conversation/v2/composer/delegate/ConversationDraftEditorState.kt +++ b/src/com/android/messaging/ui/conversation/v2/composer/delegate/ConversationDraftEditorState.kt @@ -125,13 +125,14 @@ internal data class DraftEditorState( return this } + val currentAttachments = effectiveDraft.attachments val mergedAttachments = mergeDraftAttachments( - baseAttachments = effectiveDraft.attachments, + baseAttachments = currentAttachments, attachmentsToAdd = attachments, ) return when { - mergedAttachments == effectiveDraft.attachments -> this + mergedAttachments == currentAttachments -> this else -> { copyWithNormalizedLocalEdits( @@ -144,67 +145,57 @@ internal data class DraftEditorState( } fun withAttachmentRemoved(contentUri: String): DraftEditorState { - if (conversationId == null) { - return this - } + return when { + conversationId == null -> this - val attachmentIndex = effectiveDraft.attachments.indexOfFirst { attachment -> - attachment.contentUri == contentUri - } + else -> { + val currentAttachments = effectiveDraft.attachments + val updatedAttachments = currentAttachments.withoutAttachment( + contentUri = contentUri, + ) - if (attachmentIndex == -1) { - return this + copyWithUpdatedAttachments( + currentAttachments = currentAttachments, + updatedAttachments = updatedAttachments, + ) + } } - - val updatedAttachments = effectiveDraft.attachments.toMutableList().apply { - removeAt(attachmentIndex) - }.toImmutableList() - - return copyWithNormalizedLocalEdits( - updatedLocalEdits = localEdits.copy(attachments = updatedAttachments), - ) } fun withAttachmentCaption( contentUri: String, captionText: String, ): DraftEditorState { - if (conversationId == null) { - return this - } + return when { + conversationId == null -> this - val currentAttachments = effectiveDraft.attachments - val attachmentIndex = currentAttachments.indexOfFirst { attachment -> - attachment.contentUri == contentUri - } - if (attachmentIndex == -1) { - return this - } + else -> { + val currentAttachments = effectiveDraft.attachments + val updatedAttachments = currentAttachments.withUpdatedAttachmentCaption( + contentUri = contentUri, + captionText = captionText, + ) - val currentAttachment = currentAttachments[attachmentIndex] - if (currentAttachment.captionText == captionText) { - return this + copyWithUpdatedAttachments( + currentAttachments = currentAttachments, + updatedAttachments = updatedAttachments, + ) + } } - - val updatedAttachments = currentAttachments.toMutableList().apply { - this[attachmentIndex] = currentAttachment.copy(captionText = captionText) - }.toImmutableList() - - return copyWithNormalizedLocalEdits( - updatedLocalEdits = localEdits.copy(attachments = updatedAttachments), - ) } fun withPendingAttachmentAdded( pendingAttachment: ConversationDraftPendingAttachment, ): DraftEditorState { - if (conversationId == null) { - return this - } - - val updatedPendingAttachments = pendingAttachments + pendingAttachment + return when { + conversationId == null -> this - return copy(pendingAttachments = updatedPendingAttachments) + else -> { + copy( + pendingAttachments = pendingAttachments + pendingAttachment, + ) + } + } } fun withPendingAttachmentRemoved(pendingAttachmentId: String): DraftEditorState { @@ -287,9 +278,11 @@ internal data class DraftEditorState( val visibleDraftAfterSend = when { latestEffectiveDraft == sentDraft -> clearedDraft - else -> latestEffectiveDraft.copy( - selfParticipantId = sentDraft.selfParticipantId, - ) + else -> { + latestEffectiveDraft.copy( + selfParticipantId = sentDraft.selfParticipantId, + ) + } } return copy( @@ -308,29 +301,63 @@ internal data class DraftEditorState( persistedDraft: ConversationDraft, sentDraftAwaitingClear: ConversationDraft, ): DraftEditorState { - val currentEffectiveDraft = effectiveDraft + return when { + persistedDraft == sentDraftAwaitingClear -> { + rebaseVisibleDraftOnPersistedDraft( + persistedDraft = persistedDraft, + shouldKeepPendingSentDraft = true, + ) + } - if (persistedDraft == sentDraftAwaitingClear) { - return rebaseVisibleDraftOnPersistedDraft( - persistedDraft = persistedDraft, - shouldKeepPendingSentDraft = true, - ) + else -> { + withPersistedDraftAfterSentDraftChanged( + persistedDraft = persistedDraft, + sentDraftAwaitingClear = sentDraftAwaitingClear, + ) + } } + } - val clearedDraft = createClearedDraftForSentDraft(sentDraftAwaitingClear) - if (currentEffectiveDraft == clearedDraft) { - return copy( - persistedDraft = persistedDraft, - localEdits = ConversationDraftEdits(), - isLoaded = true, - pendingSentDraft = null, - ) + private fun withPersistedDraftAfterSentDraftChanged( + persistedDraft: ConversationDraft, + sentDraftAwaitingClear: ConversationDraft, + ): DraftEditorState { + val isVisibleDraftAlreadyCleared = effectiveDraft == createClearedDraftForSentDraft( + sentDraft = sentDraftAwaitingClear, + ) + + return when { + isVisibleDraftAlreadyCleared -> { + copy( + persistedDraft = persistedDraft, + localEdits = ConversationDraftEdits(), + isLoaded = true, + pendingSentDraft = null, + ) + } + + else -> { + rebaseVisibleDraftOnPersistedDraft( + persistedDraft = persistedDraft, + shouldKeepPendingSentDraft = false, + ) + } } + } - return rebaseVisibleDraftOnPersistedDraft( - persistedDraft = persistedDraft, - shouldKeepPendingSentDraft = false, - ) + private fun copyWithUpdatedAttachments( + currentAttachments: ImmutableList, + updatedAttachments: ImmutableList, + ): DraftEditorState { + return when { + updatedAttachments == currentAttachments -> this + + else -> { + copyWithNormalizedLocalEdits( + updatedLocalEdits = localEdits.copy(attachments = updatedAttachments), + ) + } + } } private fun rebaseVisibleDraftOnPersistedDraft( @@ -426,7 +453,53 @@ private fun mergeDraftAttachments( return when { attachmentsToAppend.isEmpty() -> baseAttachments - else -> (baseAttachments + attachmentsToAppend).toImmutableList() + + else -> { + (baseAttachments + attachmentsToAppend).toImmutableList() + } + } +} + +private fun ImmutableList.withoutAttachment( + contentUri: String, +): ImmutableList { + val attachmentIndex = indexOfFirst { attachment -> + attachment.contentUri == contentUri + } + + return when { + attachmentIndex == -1 -> this + + else -> { + toMutableList() + .apply { + removeAt(attachmentIndex) + } + .toImmutableList() + } + } +} + +private fun ImmutableList.withUpdatedAttachmentCaption( + contentUri: String, + captionText: String, +): ImmutableList { + val attachmentIndex = indexOfFirst { attachment -> + attachment.contentUri == contentUri + } + val currentAttachment = getOrNull(attachmentIndex) + + return when { + currentAttachment == null -> this + currentAttachment.captionText == captionText -> this + + else -> { + toMutableList() + .apply { + this[attachmentIndex] = currentAttachment.copy(captionText = captionText) + } + .toImmutableList() + } } } diff --git a/src/com/android/messaging/ui/conversation/v2/entry/ConversationEntryViewModel.kt b/src/com/android/messaging/ui/conversation/v2/entry/ConversationEntryViewModel.kt index e37387f0..cafbe112 100644 --- a/src/com/android/messaging/ui/conversation/v2/entry/ConversationEntryViewModel.kt +++ b/src/com/android/messaging/ui/conversation/v2/entry/ConversationEntryViewModel.kt @@ -30,7 +30,7 @@ import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch -internal interface ConversationEntryModel { +internal interface ConversationEntryScreenModel { val effects: Flow val uiState: StateFlow @@ -67,7 +67,7 @@ internal class ConversationEntryViewModel @Inject constructor( @param:MainDispatcher private val mainDispatcher: CoroutineDispatcher, ) : ViewModel(), - ConversationEntryModel { + ConversationEntryScreenModel { private val _effects = MutableSharedFlow( extraBufferCapacity = 1, @@ -117,33 +117,22 @@ internal class ConversationEntryViewModel @Inject constructor( } override fun onCreateGroupRecipientClicked(destination: String) { - val state = editableGroupStateOrNull() ?: return - val current = state.selectedGroupRecipientDestinations - val trimmed = destination.trim() - - val updatedDestinations = when { - trimmed.isEmpty() -> { - return - } - - trimmed in current -> { - current - trimmed - } + val editableGroupState = editableGroupStateOrNull() - canAcceptRecipientCount(count = current.size + 1) -> { - current + trimmed + editableGroupState + ?.let { editableGroupState -> + updatedGroupRecipientDestinationsOrNull( + currentDestinations = editableGroupState.selectedGroupRecipientDestinations, + destination = destination, + ) } - - else -> { - return + ?.let { updatedDestinations -> + updateUiState( + editableGroupState.copy( + selectedGroupRecipientDestinations = updatedDestinations.toImmutableList(), + ), + ) } - } - - updateUiState( - state.copy( - selectedGroupRecipientDestinations = updatedDestinations.toImmutableList(), - ), - ) } override fun onCreateGroupConfirmed() { @@ -355,6 +344,24 @@ internal class ConversationEntryViewModel @Inject constructor( } } + private fun updatedGroupRecipientDestinationsOrNull( + currentDestinations: List, + destination: String, + ): List? { + val trimmedDestination = destination.trim() + + return when { + trimmedDestination.isEmpty() -> null + trimmedDestination in currentDestinations -> currentDestinations - trimmedDestination + + canAcceptRecipientCount(count = currentDestinations.size + 1) -> { + currentDestinations + trimmedDestination + } + + else -> null + } + } + private fun canAcceptRecipientCount(count: Int): Boolean { if (isConversationRecipientLimitExceeded(count)) { showMessage(messageResId = R.string.too_many_participants) diff --git a/src/com/android/messaging/ui/conversation/v2/mediapicker/camera/ConversationCameraController.kt b/src/com/android/messaging/ui/conversation/v2/mediapicker/camera/ConversationCameraController.kt index 85a53019..f23010b4 100644 --- a/src/com/android/messaging/ui/conversation/v2/mediapicker/camera/ConversationCameraController.kt +++ b/src/com/android/messaging/ui/conversation/v2/mediapicker/camera/ConversationCameraController.kt @@ -150,18 +150,17 @@ private class ConversationCameraControllerImpl( } override fun stopVideoRecording() { - val recording = updateRecordingDiscardOnFinalize(discardOnFinalize = false) ?: return - recording.stop() + updateRecordingDiscardOnFinalize(discardOnFinalize = false)?.stop() } override fun cancelVideoRecording() { - val recording = updateRecordingDiscardOnFinalize(discardOnFinalize = true) ?: return - recording.stop() + updateRecordingDiscardOnFinalize(discardOnFinalize = true)?.stop() } override fun switchCamera(onError: (Throwable) -> Unit) { - val currentBoundCameraSession = - getBoundCameraSessionOrReportError(onError = onError) ?: return + val currentBoundCameraSession = getBoundCameraSessionOrReportError(onError = onError) + ?: return + val targetLensFacing = resolveSwitchTargetLensFacing( currentLensFacing = currentBoundCameraSession.lensFacing, ) @@ -179,14 +178,16 @@ private class ConversationCameraControllerImpl( } override fun cyclePhotoFlashMode(onError: (Throwable) -> Unit) { - val currentBoundCameraSession = - getBoundCameraSessionOrReportError(onError = onError) ?: return + val currentBoundCameraSession = getBoundCameraSessionOrReportError(onError = onError) + ?: return + if (!_hasFlashUnit.value) { onError(FlashUnavailableException()) return } val nextPhotoFlashMode = _photoFlashMode.value.next() + runCatching { updatePhotoFlashMode( imageCapture = currentBoundCameraSession.imageCapture, @@ -208,9 +209,7 @@ private class ConversationCameraControllerImpl( _hasFlashUnit.value = false } - private fun syncBoundImageCaptureFlashMode( - imageCapture: ImageCapture, - ) { + private fun syncBoundImageCaptureFlashMode(imageCapture: ImageCapture) { updatePhotoFlashMode( imageCapture = imageCapture, photoFlashMode = preferredPhotoFlashMode, @@ -230,6 +229,7 @@ private class ConversationCameraControllerImpl( onError: (Throwable) -> Unit, ) { val cameraProviderFuture = ProcessCameraProvider.getInstance(applicationContext) + cameraProviderFuture.addListener( { handleCameraProviderReady( @@ -279,6 +279,7 @@ private class ConversationCameraControllerImpl( val selectedLensFacing = resolveBindLensFacing( processCameraProvider = processCameraProvider, ) + val selectedCameraSelector = buildCameraSelector(lensFacing = selectedLensFacing) val boundUseCases = createBoundUseCases() val camera = processCameraProvider.bindToLifecycle( @@ -311,11 +312,13 @@ private class ConversationCameraControllerImpl( } private fun createPreviewUseCase(): Preview { - return Preview.Builder().build().also { previewUseCase -> - previewUseCase.setSurfaceProvider { surfaceRequest -> - _surfaceRequest.value = surfaceRequest + return Preview.Builder() + .build() + .also { previewUseCase -> + previewUseCase.setSurfaceProvider { surfaceRequest -> + _surfaceRequest.value = surfaceRequest + } } - } } private fun createImageCaptureUseCase(): ImageCapture { @@ -345,10 +348,8 @@ private class ConversationCameraControllerImpl( return null } - val currentBoundCameraSession = getBoundCameraSessionOrReportError(onError = onError) - ?: return null - - return currentBoundCameraSession.imageCapture + return getBoundCameraSessionOrReportError(onError = onError) + ?.imageCapture } private fun createPhotoOutputOrReportError( @@ -361,7 +362,6 @@ private class ConversationCameraControllerImpl( mediaLabel = "photo", ), ) - return null } return photoOutput @@ -456,7 +456,6 @@ private class ConversationCameraControllerImpl( mediaLabel = "video", ), ) - return null } return videoOutput @@ -517,9 +516,13 @@ private class ConversationCameraControllerImpl( ) } - is VideoRecordEvent.Start -> handleVideoRecordingStarted() + is VideoRecordEvent.Start -> { + handleVideoRecordingStarted() + } - is VideoRecordEvent.Status -> handleVideoRecordingStatus(event = event) + is VideoRecordEvent.Status -> { + handleVideoRecordingStatus(event = event) + } } } @@ -669,11 +672,9 @@ private class ConversationCameraControllerImpl( } private fun deleteScratchOutput(scratchOutput: ScratchOutput?) { - if (scratchOutput == null) { - return + if (scratchOutput != null) { + applicationContext.contentResolver.delete(scratchOutput.uri, null, null) } - - applicationContext.contentResolver.delete(scratchOutput.uri, null, null) } private fun isCurrentBindGeneration(bindGeneration: Long): Boolean { @@ -686,7 +687,6 @@ private class ConversationCameraControllerImpl( val currentBoundCameraSession = boundCameraSession if (currentBoundCameraSession == null) { onError(CameraNotBoundException()) - return null } return currentBoundCameraSession @@ -694,6 +694,7 @@ private class ConversationCameraControllerImpl( private fun updateRecordingDiscardOnFinalize(discardOnFinalize: Boolean): Recording? { val currentRecordingSession = activeRecordingSession ?: return null + activeRecordingSession = currentRecordingSession.copy( discardOnFinalize = discardOnFinalize, ) diff --git a/src/com/android/messaging/ui/conversation/v2/messages/delegate/ConversationMessageSelectionDelegate.kt b/src/com/android/messaging/ui/conversation/v2/messages/delegate/ConversationMessageSelectionDelegate.kt index 034f1201..8178e3a5 100644 --- a/src/com/android/messaging/ui/conversation/v2/messages/delegate/ConversationMessageSelectionDelegate.kt +++ b/src/com/android/messaging/ui/conversation/v2/messages/delegate/ConversationMessageSelectionDelegate.kt @@ -173,11 +173,10 @@ internal class ConversationMessageSelectionDelegateImpl @Inject constructor( val messageId = pendingDefaultSmsRoleResendMessageId ?: return false pendingDefaultSmsRoleResendMessageId = null - if (resultCode != Activity.RESULT_OK) { - return true + if (resultCode == Activity.RESULT_OK) { + resendMessageWhenActionRequirementsSatisfied(messageId = messageId) } - resendMessageWhenActionRequirementsSatisfied(messageId = messageId) return true } @@ -452,97 +451,104 @@ internal class ConversationMessageSelectionDelegateImpl @Inject constructor( ) } } -} -private fun buildMessageSelectionUiState( - messagesUiState: ConversationMessagesUiState, - selectionState: ConversationMessageSelectionState, -): ConversationMessageSelectionUiState { - val messages = when (messagesUiState) { - is ConversationMessagesUiState.Present -> messagesUiState.messages - ConversationMessagesUiState.Loading -> return ConversationMessageSelectionUiState() - } + private fun buildMessageSelectionUiState( + messagesUiState: ConversationMessagesUiState, + selectionState: ConversationMessageSelectionState, + ): ConversationMessageSelectionUiState { + val messages = when (messagesUiState) { + is ConversationMessagesUiState.Present -> messagesUiState.messages + ConversationMessagesUiState.Loading -> return ConversationMessageSelectionUiState() + } - val messagesById = messages.associateBy(ConversationMessageUiModel::messageId) - val currentMessageIds = messagesById.keys + val messagesById = messages.associateBy(ConversationMessageUiModel::messageId) + val currentMessageIds = messagesById.keys - val selectedMessageIds = selectionState - .selectedMessageIds - .asSequence() - .filter(currentMessageIds::contains) - .toImmutableSet() + val selectedMessageIds = selectionState + .selectedMessageIds + .asSequence() + .filter(currentMessageIds::contains) + .toImmutableSet() + + val pendingDeleteMessageIds = selectionState + .pendingDeleteMessageIds + .asSequence() + .filter(currentMessageIds::contains) + .toImmutableSet() - val pendingDeleteMessageIds = selectionState - .pendingDeleteMessageIds - .asSequence() - .filter(currentMessageIds::contains) - .toImmutableSet() + val selectedMessage = when (selectedMessageIds.size) { + 1 -> messagesById[selectedMessageIds.first()] + else -> null + } - val selectedMessage = when (selectedMessageIds.size) { - 1 -> messagesById[selectedMessageIds.first()] - else -> null + return ConversationMessageSelectionUiState( + selectedMessageIds = selectedMessageIds, + availableActions = availableSelectionActions( + selectedMessage = selectedMessage, + selectedMessageCount = selectedMessageIds.size, + ), + deleteConfirmation = pendingDeleteMessageIds + .takeIf { messageIds -> + messageIds.isNotEmpty() + } + ?.let { messageIds -> + ConversationMessageDeleteConfirmationUiState( + messageIds = messageIds, + ) + }, + ) } - return ConversationMessageSelectionUiState( - selectedMessageIds = selectedMessageIds, - availableActions = availableSelectionActions( - selectedMessage = selectedMessage, - selectedMessageCount = selectedMessageIds.size, - ), - deleteConfirmation = pendingDeleteMessageIds - .takeIf { messageIds -> - messageIds.isNotEmpty() - } - ?.let { messageIds -> - ConversationMessageDeleteConfirmationUiState( - messageIds = messageIds, + private fun availableSelectionActions( + selectedMessage: ConversationMessageUiModel?, + selectedMessageCount: Int, + ): ImmutableSet { + return when { + selectedMessageCount <= 0 -> persistentSetOf() + selectedMessageCount > 1 || selectedMessage == null -> { + persistentSetOf( + ConversationMessageSelectionAction.Delete, ) - }, - ) -} - -private fun availableSelectionActions( - selectedMessage: ConversationMessageUiModel?, - selectedMessageCount: Int, -): ImmutableSet { - if (selectedMessageCount <= 0) { - return persistentSetOf() - } + } - if (selectedMessageCount > 1 || selectedMessage == null) { - return persistentSetOf( - ConversationMessageSelectionAction.Delete, - ) + else -> { + availableSingleMessageSelectionActions(selectedMessage = selectedMessage) + } + } } - val actions = LinkedHashSet() + private fun availableSingleMessageSelectionActions( + selectedMessage: ConversationMessageUiModel, + ): ImmutableSet { + val actions = LinkedHashSet() - if (selectedMessage.canDownloadMessage) { - actions += ConversationMessageSelectionAction.Download - } + if (selectedMessage.canDownloadMessage) { + actions += ConversationMessageSelectionAction.Download + } - if (selectedMessage.canResendMessage) { - actions += ConversationMessageSelectionAction.Resend - } + if (selectedMessage.canResendMessage) { + actions += ConversationMessageSelectionAction.Resend + } - actions += ConversationMessageSelectionAction.Delete + actions += ConversationMessageSelectionAction.Delete - if (selectedMessage.canForwardMessage) { - actions += ConversationMessageSelectionAction.Share - actions += ConversationMessageSelectionAction.Forward - } + if (selectedMessage.canForwardMessage) { + actions += ConversationMessageSelectionAction.Share + actions += ConversationMessageSelectionAction.Forward + } - if (selectedMessage.canSaveAttachments) { - actions += ConversationMessageSelectionAction.SaveAttachment - } + if (selectedMessage.canSaveAttachments) { + actions += ConversationMessageSelectionAction.SaveAttachment + } - if (selectedMessage.canCopyMessageToClipboard) { - actions += ConversationMessageSelectionAction.Copy - } + if (selectedMessage.canCopyMessageToClipboard) { + actions += ConversationMessageSelectionAction.Copy + } - actions += ConversationMessageSelectionAction.Details + actions += ConversationMessageSelectionAction.Details - return actions.toImmutableSet() + return actions.toImmutableSet() + } } private data class ConversationMessageSelectionState( diff --git a/src/com/android/messaging/ui/conversation/v2/messages/ui/ConversationMessages.kt b/src/com/android/messaging/ui/conversation/v2/messages/ui/ConversationMessages.kt index b0c931fb..5f089e0d 100644 --- a/src/com/android/messaging/ui/conversation/v2/messages/ui/ConversationMessages.kt +++ b/src/com/android/messaging/ui/conversation/v2/messages/ui/ConversationMessages.kt @@ -310,10 +310,24 @@ private fun shouldShowDateSeparator( messageAbove: ConversationMessageUiModel?, timeZone: TimeZone, ): Boolean { - if (messageAbove == null) { - return true + return when (messageAbove) { + null -> true + + else -> { + shouldShowDateSeparatorBetweenMessages( + currentMessage = currentMessage, + messageAbove = messageAbove, + timeZone = timeZone, + ) + } } +} +private fun shouldShowDateSeparatorBetweenMessages( + currentMessage: ConversationMessageUiModel, + messageAbove: ConversationMessageUiModel, + timeZone: TimeZone, +): Boolean { val currentEpochDay = conversationMessageDisplayEpochDay( displayTimestamp = currentMessage.displayTimestamp, timeZone = timeZone, diff --git a/src/com/android/messaging/ui/conversation/v2/messages/ui/attachment/ConversationInlineAudioAttachmentPlaybackState.kt b/src/com/android/messaging/ui/conversation/v2/messages/ui/attachment/ConversationInlineAudioAttachmentPlaybackState.kt index 0f798a76..51b10239 100644 --- a/src/com/android/messaging/ui/conversation/v2/messages/ui/attachment/ConversationInlineAudioAttachmentPlaybackState.kt +++ b/src/com/android/messaging/ui/conversation/v2/messages/ui/attachment/ConversationInlineAudioAttachmentPlaybackState.kt @@ -99,28 +99,33 @@ internal class ConversationInlineAudioAttachmentPlaybackState( ) { val currentMediaPlayer = mediaPlayer - if (currentMediaPlayer == null) { - shouldStartPlaybackWhenPrepared = true - ensureMediaPlayer( - context = context, - contentUri = contentUri, - ) - return - } + when { + currentMediaPlayer == null -> { + shouldStartPlaybackWhenPrepared = true + ensureMediaPlayer( + context = context, + contentUri = contentUri, + ) + } - if (!isPrepared) { - shouldStartPlaybackWhenPrepared = !shouldStartPlaybackWhenPrepared - return - } + !isPrepared -> { + shouldStartPlaybackWhenPrepared = !shouldStartPlaybackWhenPrepared + } - if (isPlaying) { - currentMediaPlayer.pause() - positionMillis = currentMediaPlayer.currentPosition.toLong().coerceAtLeast(0L) - isPlaying = false - return - } + isPlaying -> { + currentMediaPlayer.pause() + positionMillis = currentMediaPlayer + .currentPosition + .toLong() + .coerceAtLeast(0L) - startPlayback() + isPlaying = false + } + + else -> { + startPlayback() + } + } } fun updateProgress() { diff --git a/src/com/android/messaging/ui/conversation/v2/messages/ui/message/ConversationMessage.kt b/src/com/android/messaging/ui/conversation/v2/messages/ui/message/ConversationMessage.kt index e4e05814..01753b7d 100644 --- a/src/com/android/messaging/ui/conversation/v2/messages/ui/message/ConversationMessage.kt +++ b/src/com/android/messaging/ui/conversation/v2/messages/ui/message/ConversationMessage.kt @@ -364,15 +364,11 @@ private fun clusteredCornerRadius( useFreeSide: Boolean = false, defaultRadius: Dp = MESSAGE_BUBBLE_CORNER_RADIUS_DP.dp, ): Dp { - if (!clustersWithAdjacent) { - return defaultRadius - } - - if (useFreeSide) { - return defaultRadius + return when { + !clustersWithAdjacent -> defaultRadius + useFreeSide -> defaultRadius + else -> MESSAGE_BUBBLE_CONNECTED_CORNER_RADIUS_DP.dp } - - return MESSAGE_BUBBLE_CONNECTED_CORNER_RADIUS_DP.dp } private fun buildConversationMessageBubbleLayoutMode( @@ -402,25 +398,33 @@ private fun buildMessageMetadataText( timestamp: Long, statusText: String?, ): String? { - if (canClusterWithNext) { - return null - } + return when { + canClusterWithNext -> null + timestamp <= 0L -> statusText + + else -> { + val formattedTime = DateUtils.formatDateTime( + context, + timestamp, + DateUtils.FORMAT_SHOW_TIME, + ) - if (timestamp <= 0L) { - return statusText + buildTimestampMetadataText( + formattedTime = formattedTime, + statusText = statusText, + ) + } } +} - val formattedTime = DateUtils.formatDateTime( - context, - timestamp, - DateUtils.FORMAT_SHOW_TIME, - ) - - if (statusText == null) { - return formattedTime +private fun buildTimestampMetadataText( + formattedTime: String, + statusText: String?, +): String { + return when (statusText) { + null -> formattedTime + else -> "$formattedTime \u2022 $statusText" } - - return "$formattedTime \u2022 $statusText" } @Suppress("CyclomaticComplexMethod") diff --git a/src/com/android/messaging/ui/conversation/v2/messages/ui/message/ConversationMessageBubble.kt b/src/com/android/messaging/ui/conversation/v2/messages/ui/message/ConversationMessageBubble.kt index 4bac5156..60e915b0 100644 --- a/src/com/android/messaging/ui/conversation/v2/messages/ui/message/ConversationMessageBubble.kt +++ b/src/com/android/messaging/ui/conversation/v2/messages/ui/message/ConversationMessageBubble.kt @@ -18,13 +18,11 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.android.messaging.ui.conversation.v2.messages.model.message.ConversationMessageContent import com.android.messaging.ui.conversation.v2.messages.model.message.ConversationMessageUiModel -import com.android.messaging.ui.conversation.v2.messages.model.message.ConversationMessageUiModel.Status import com.android.messaging.ui.conversation.v2.messages.ui.attachment.ConversationMessageAttachments import com.android.messaging.ui.conversation.v2.messages.ui.text.ConversationMessageText @@ -180,29 +178,6 @@ private fun ConversationMessageTextSurfaceBubble( } } -@Composable -internal fun ConversationMessageMetadata( - message: ConversationMessageUiModel, - metadataText: String?, -) { - if (metadataText == null) { - return - } - - Text( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 6.dp), - text = metadataText, - style = MaterialTheme.typography.labelSmall, - color = messageMetadataColor(message = message), - textAlign = when { - message.isIncoming -> TextAlign.Start - else -> TextAlign.End - }, - ) -} - @Composable private fun ConversationMessageBubbleSurface( modifier: Modifier = Modifier, @@ -489,19 +464,3 @@ private fun messageSenderColor( } } } - -@Composable -private fun messageMetadataColor( - message: ConversationMessageUiModel, -): Color { - return when (message.status) { - Status.Outgoing.AwaitingRetry, - Status.Outgoing.Failed, - Status.Outgoing.FailedEmergencyNumber, - Status.Incoming.DownloadFailed, - Status.Incoming.ExpiredOrNotAvailable, - -> MaterialTheme.colorScheme.error - - else -> MaterialTheme.colorScheme.onSurfaceVariant - } -} diff --git a/src/com/android/messaging/ui/conversation/v2/messages/ui/message/ConversationMessageContentBuilder.kt b/src/com/android/messaging/ui/conversation/v2/messages/ui/message/ConversationMessageContentBuilder.kt index e6d39b28..63c601b3 100644 --- a/src/com/android/messaging/ui/conversation/v2/messages/ui/message/ConversationMessageContentBuilder.kt +++ b/src/com/android/messaging/ui/conversation/v2/messages/ui/message/ConversationMessageContentBuilder.kt @@ -21,7 +21,6 @@ internal fun buildConversationMessageContent( val bodyText = buildConversationMessageBodyText( message = message, - attachments = attachments, ) val isAttachmentOnly = subjectText.isNullOrBlank() && @@ -105,10 +104,7 @@ private fun buildConversationMessageAttachmentKey( } } -private fun buildConversationMessageBodyText( - message: ConversationMessageUiModel, - attachments: ImmutableList, -): String? { +private fun buildConversationMessageBodyText(message: ConversationMessageUiModel): String? { message.text ?.trim() ?.takeIf { it.isNotEmpty() } @@ -116,7 +112,7 @@ private fun buildConversationMessageBodyText( return bodyText } - val captionText = message.parts + return message.parts .asSequence() .filter { it.hasCaptionText } .mapNotNull { part -> @@ -125,11 +121,6 @@ private fun buildConversationMessageBodyText( .distinct() .joinToString(separator = "\n") .takeIf { text -> text.isNotEmpty() } - - return when { - captionText != null -> captionText - else -> null - } } private fun ConversationMessagePartUiModel.Attachment.isSupportedAttachment(): Boolean { diff --git a/src/com/android/messaging/ui/conversation/v2/messages/ui/message/ConversationMessageMetadata.kt b/src/com/android/messaging/ui/conversation/v2/messages/ui/message/ConversationMessageMetadata.kt new file mode 100644 index 00000000..822a478e --- /dev/null +++ b/src/com/android/messaging/ui/conversation/v2/messages/ui/message/ConversationMessageMetadata.kt @@ -0,0 +1,50 @@ +package com.android.messaging.ui.conversation.v2.messages.ui.message + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.android.messaging.ui.conversation.v2.messages.model.message.ConversationMessageUiModel +import com.android.messaging.ui.conversation.v2.messages.model.message.ConversationMessageUiModel.Status + +@Composable +internal fun ConversationMessageMetadata( + message: ConversationMessageUiModel, + metadataText: String?, +) { + metadataText?.let { + Text( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 6.dp), + text = metadataText, + style = MaterialTheme.typography.labelSmall, + color = messageMetadataColor(message = message), + textAlign = when { + message.isIncoming -> TextAlign.Start + else -> TextAlign.End + }, + ) + } +} + +@Composable +private fun messageMetadataColor( + message: ConversationMessageUiModel, +): Color { + return when (message.status) { + Status.Outgoing.AwaitingRetry, + Status.Outgoing.Failed, + Status.Outgoing.FailedEmergencyNumber, + Status.Incoming.DownloadFailed, + Status.Incoming.ExpiredOrNotAvailable, + -> MaterialTheme.colorScheme.error + + else -> MaterialTheme.colorScheme.onSurfaceVariant + } +} diff --git a/src/com/android/messaging/ui/conversation/v2/navigation/ConversationNavGraph.kt b/src/com/android/messaging/ui/conversation/v2/navigation/ConversationNavGraph.kt index f5383807..160093f3 100644 --- a/src/com/android/messaging/ui/conversation/v2/navigation/ConversationNavGraph.kt +++ b/src/com/android/messaging/ui/conversation/v2/navigation/ConversationNavGraph.kt @@ -19,7 +19,7 @@ import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator import androidx.navigation3.ui.NavDisplay import com.android.messaging.data.conversation.model.draft.ConversationDraft import com.android.messaging.ui.conversation.v2.addparticipants.AddParticipantsScreen -import com.android.messaging.ui.conversation.v2.entry.ConversationEntryModel +import com.android.messaging.ui.conversation.v2.entry.ConversationEntryScreenModel import com.android.messaging.ui.conversation.v2.entry.ConversationEntryViewModel import com.android.messaging.ui.conversation.v2.entry.NewChatScreen import com.android.messaging.ui.conversation.v2.entry.model.ConversationEntryEffect @@ -36,7 +36,7 @@ internal fun ConversationNavGraph( modifier: Modifier = Modifier, onConversationDetailsClick: (String) -> Unit = {}, onFinish: () -> Unit, - entryModel: ConversationEntryModel = hiltViewModel(), + entryModel: ConversationEntryScreenModel = hiltViewModel(), navigationReducer: ConversationNavigationReducer = defaultConversationNavReducer, ) { val entryUiState by entryModel.uiState.collectAsStateWithLifecycle() @@ -262,7 +262,7 @@ private fun popBackStackOrFinish( private fun handleNavBack( backStack: MutableList, - entryModel: ConversationEntryModel, + entryModel: ConversationEntryScreenModel, entryUiState: ConversationEntryUiState, navigationReducer: ConversationNavigationReducer, onFinish: () -> Unit, @@ -280,7 +280,7 @@ private fun handleNavBack( } private fun handleNewChatBack( - entryModel: ConversationEntryModel, + entryModel: ConversationEntryScreenModel, entryUiState: ConversationEntryUiState, backStack: MutableList, navigationReducer: ConversationNavigationReducer, @@ -315,7 +315,7 @@ private fun pendingLaunchPayloadForConversation( private class ConversationNavRouteState( val backStack: MutableList, - val entryModel: State, + val entryModel: State, val entryUiState: State, val isLaunchedFromBubble: State, val navigationReducer: State, @@ -342,7 +342,7 @@ private data class ConversationPendingLaunchPayload( private fun conversationNavEffectState( routeState: ConversationNavRouteState, - entryModel: ConversationEntryModel, + entryModel: ConversationEntryScreenModel, ): ConversationNavEffectState { return ConversationNavEffectState( onLaunchRequest = entryModel::onLaunchRequest, diff --git a/src/com/android/messaging/ui/conversation/v2/screen/ConversationScreenEffects.kt b/src/com/android/messaging/ui/conversation/v2/screen/ConversationScreenEffects.kt index 6951a5c9..0caeb43b 100644 --- a/src/com/android/messaging/ui/conversation/v2/screen/ConversationScreenEffects.kt +++ b/src/com/android/messaging/ui/conversation/v2/screen/ConversationScreenEffects.kt @@ -195,7 +195,7 @@ private fun handleImmediateConversationScreenEffect( ) } - else -> Unit + else -> {} } } @@ -387,8 +387,12 @@ private fun openImageAttachmentPreview( attachmentUri: Uri, imageCollectionUri: String?, ): Boolean { - val activity = UiUtils.getActivity(context) ?: return false - val imageCollection = imageCollectionUri?.toUri() ?: return false + val activity = UiUtils.getActivity(context) + val imageCollection = imageCollectionUri?.toUri() + + if (activity == null || imageCollection == null) { + return false + } UIIntents.get().launchFullScreenPhotoViewer( activity, diff --git a/src/com/android/messaging/ui/conversation/v2/screen/ConversationViewModel.kt b/src/com/android/messaging/ui/conversation/v2/screen/ConversationViewModel.kt index 4c3a8474..7f61f44a 100644 --- a/src/com/android/messaging/ui/conversation/v2/screen/ConversationViewModel.kt +++ b/src/com/android/messaging/ui/conversation/v2/screen/ConversationViewModel.kt @@ -343,12 +343,9 @@ internal class ConversationViewModel @Inject constructor( private fun canAddPeople( metadataState: ConversationMetadataUiState, ): Boolean { - return when (metadataState) { - is ConversationMetadataUiState.Present -> { - canAddMoreConversationParticipants( - participantCount = metadataState.participantCount, - ) - } + return when { + metadataState !is ConversationMetadataUiState.Present -> false + canAddMoreConversationParticipants(metadataState.participantCount) -> true else -> false } } @@ -356,31 +353,26 @@ internal class ConversationViewModel @Inject constructor( private fun canCall( metadataState: ConversationMetadataUiState, ): Boolean { - if (metadataState !is ConversationMetadataUiState.Present) { - return false - } - - val phoneNumber = metadataState.otherParticipantPhoneNumber - if (metadataState.participantCount != 1 || phoneNumber == null) { - return false + return when { + metadataState !is ConversationMetadataUiState.Present -> false + metadataState.participantCount != 1 -> false + metadataState.otherParticipantPhoneNumber == null -> false + !isDeviceVoiceCapable() -> false + isEmergencyPhoneNumber(metadataState.otherParticipantPhoneNumber) -> false + else -> true } - - return isDeviceVoiceCapable() && !isEmergencyPhoneNumber(phoneNumber = phoneNumber) } private fun canAddContact( metadataState: ConversationMetadataUiState, ): Boolean { - if (metadataState !is ConversationMetadataUiState.Present) { - return false + return when { + metadataState !is ConversationMetadataUiState.Present -> false + metadataState.participantCount != 1 -> false + metadataState.otherParticipantPhoneNumber.isNullOrBlank() -> false + !metadataState.otherParticipantContactLookupKey.isNullOrBlank() -> false + else -> true } - - val hasDestination = !metadataState.otherParticipantPhoneNumber.isNullOrBlank() - val hasContactLink = !metadataState.otherParticipantContactLookupKey.isNullOrBlank() - - return metadataState.participantCount == 1 && - hasDestination && - !hasContactLink } override fun onSeedDraft( From 9c95f0635a4425d8af11e112e974a28580b8fc2a Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Fri, 1 May 2026 10:41:19 +0300 Subject: [PATCH 90/99] Improve packages organization --- .../ConversationVCardMetadataMapper.kt | 6 +- .../ConversationVCardAttachmentMetadata.kt | 2 +- .../ConversationVCardAttachmentType.kt | 9 ++ .../model/draft/PhotoPickerDraftAttachment.kt | 6 ++ .../ConversationDraftsRepository.kt | 1 + .../ConversationVCardMetadataRepository.kt | 5 +- .../ConversationDraftStore.kt | 2 +- .../media}/model/AttachmentToSave.kt | 2 +- .../media}/model/ConversationCapturedMedia.kt | 2 +- .../model/PhotoPickerDraftAttachmentResult.kt | 4 +- .../media/model}/SaveAttachmentsResult.kt | 2 +- .../ConversationAttachmentRepository.kt | 9 +- .../conversation/ConversationBindsModule.kt | 20 ++-- .../ConversationViewModelBindsModule.kt | 4 +- ...onversationVCardAttachmentUiModelMapper.kt | 8 +- .../ConversationVCardAttachmentUiModel.kt | 9 +- .../ui}/ConversationMediaThumbnail.kt | 2 +- .../ConversationMediaThumbnailBitmapLoader.kt | 2 +- .../ConversationVCardAttachmentCardContent.kt | 98 +++++++++++++++++++ .../ConversationAudioRecordingDelegate.kt | 2 +- ...ConversationComposerAttachmentsDelegate.kt | 6 +- ...ersationComposerAttachmentUiModelMapper.kt | 4 +- .../model/ComposerAttachmentUiModel.kt | 2 +- .../ui/ConversationAttachmentPreview.kt | 6 +- .../v2/mediapicker/ConversationMediaPicker.kt | 2 +- .../ConversationMediaPickerCaptureRoute.kt | 2 +- .../ConversationMediaPickerCaptureScene.kt | 2 +- .../ConversationMediaPickerOverlay.kt | 2 +- .../ConversationMediaPickerScaffold.kt | 2 +- .../camera/ConversationCameraController.kt | 2 +- .../camera/ConversationMediaPickerActions.kt | 2 +- .../ConversationMediaReviewBackground.kt | 2 +- .../review/ConversationMediaReviewPageCard.kt | 2 +- .../ConversationMediaPickerDelegate.kt | 10 +- .../ConversationDraftAttachmentMapper.kt | 2 +- .../model/PhotoPickerDraftAttachment.kt | 8 -- .../ConversationMessageSelectionDelegate.kt | 4 +- .../delegate/ConversationMessagesDelegate.kt | 6 +- .../ConversationMessageUiModelMapper.kt | 1 + .../ConversationInlineAttachment.kt | 1 + .../message/ConversationMessagePartUiModel.kt | 2 +- .../ConversationAttachmentSectionsBuilder.kt | 2 +- .../ConversationVCardInlineAttachmentRow.kt | 93 +----------------- .../ConversationVisualAttachments.kt | 2 +- .../v2/screen/ConversationViewModel.kt | 4 +- 45 files changed, 191 insertions(+), 175 deletions(-) rename src/com/android/messaging/{ui/conversation/v2/messages/repository => data/conversation/mapper}/ConversationVCardMetadataMapper.kt (84%) rename src/com/android/messaging/{ui/conversation/v2/messages => data/conversation}/model/attachment/ConversationVCardAttachmentMetadata.kt (88%) create mode 100644 src/com/android/messaging/data/conversation/model/attachment/ConversationVCardAttachmentType.kt create mode 100644 src/com/android/messaging/data/conversation/model/draft/PhotoPickerDraftAttachment.kt rename src/com/android/messaging/{ui/conversation/v2/messages => data/conversation}/repository/ConversationVCardMetadataRepository.kt (90%) rename src/com/android/messaging/data/conversation/{repository => store}/ConversationDraftStore.kt (96%) rename src/com/android/messaging/{ui/conversation/v2/mediapicker => data/media}/model/AttachmentToSave.kt (59%) rename src/com/android/messaging/{ui/conversation/v2/mediapicker => data/media}/model/ConversationCapturedMedia.kt (70%) rename src/com/android/messaging/{ui/conversation/v2/mediapicker => data/media}/model/PhotoPickerDraftAttachmentResult.kt (69%) rename src/com/android/messaging/{ui/conversation/v2/mediapicker/repository => data/media/model}/SaveAttachmentsResult.kt (66%) rename src/com/android/messaging/{ui/conversation/v2/mediapicker => data/media}/repository/ConversationAttachmentRepository.kt (98%) rename src/com/android/messaging/ui/conversation/v2/{messages => attachment}/mapper/ConversationVCardAttachmentUiModelMapper.kt (92%) rename src/com/android/messaging/ui/conversation/v2/{messages/model/attachment => attachment/model}/ConversationVCardAttachmentUiModel.kt (64%) rename src/com/android/messaging/ui/conversation/v2/{mediapicker/component => attachment/ui}/ConversationMediaThumbnail.kt (99%) rename src/com/android/messaging/ui/conversation/v2/{mediapicker/component => attachment/ui}/ConversationMediaThumbnailBitmapLoader.kt (99%) create mode 100644 src/com/android/messaging/ui/conversation/v2/attachment/ui/ConversationVCardAttachmentCardContent.kt rename src/com/android/messaging/ui/conversation/v2/mediapicker/{ => delegate}/ConversationMediaPickerDelegate.kt (96%) delete mode 100644 src/com/android/messaging/ui/conversation/v2/mediapicker/model/PhotoPickerDraftAttachment.kt diff --git a/src/com/android/messaging/ui/conversation/v2/messages/repository/ConversationVCardMetadataMapper.kt b/src/com/android/messaging/data/conversation/mapper/ConversationVCardMetadataMapper.kt similarity index 84% rename from src/com/android/messaging/ui/conversation/v2/messages/repository/ConversationVCardMetadataMapper.kt rename to src/com/android/messaging/data/conversation/mapper/ConversationVCardMetadataMapper.kt index 07cd720b..c047bc87 100644 --- a/src/com/android/messaging/ui/conversation/v2/messages/repository/ConversationVCardMetadataMapper.kt +++ b/src/com/android/messaging/data/conversation/mapper/ConversationVCardMetadataMapper.kt @@ -1,9 +1,9 @@ -package com.android.messaging.ui.conversation.v2.messages.repository +package com.android.messaging.data.conversation.mapper +import com.android.messaging.data.conversation.model.attachment.ConversationVCardAttachmentMetadata +import com.android.messaging.data.conversation.model.attachment.ConversationVCardAttachmentType import com.android.messaging.datamodel.data.VCardContactItemData import com.android.messaging.datamodel.media.VCardResourceEntry -import com.android.messaging.ui.conversation.v2.messages.model.attachment.ConversationVCardAttachmentMetadata -import com.android.messaging.ui.conversation.v2.messages.model.attachment.ConversationVCardAttachmentType import javax.inject.Inject internal interface ConversationVCardMetadataMapper { diff --git a/src/com/android/messaging/ui/conversation/v2/messages/model/attachment/ConversationVCardAttachmentMetadata.kt b/src/com/android/messaging/data/conversation/model/attachment/ConversationVCardAttachmentMetadata.kt similarity index 88% rename from src/com/android/messaging/ui/conversation/v2/messages/model/attachment/ConversationVCardAttachmentMetadata.kt rename to src/com/android/messaging/data/conversation/model/attachment/ConversationVCardAttachmentMetadata.kt index a810e92d..7d36c052 100644 --- a/src/com/android/messaging/ui/conversation/v2/messages/model/attachment/ConversationVCardAttachmentMetadata.kt +++ b/src/com/android/messaging/data/conversation/model/attachment/ConversationVCardAttachmentMetadata.kt @@ -1,4 +1,4 @@ -package com.android.messaging.ui.conversation.v2.messages.model.attachment +package com.android.messaging.data.conversation.model.attachment import androidx.compose.runtime.Immutable diff --git a/src/com/android/messaging/data/conversation/model/attachment/ConversationVCardAttachmentType.kt b/src/com/android/messaging/data/conversation/model/attachment/ConversationVCardAttachmentType.kt new file mode 100644 index 00000000..012b077c --- /dev/null +++ b/src/com/android/messaging/data/conversation/model/attachment/ConversationVCardAttachmentType.kt @@ -0,0 +1,9 @@ +package com.android.messaging.data.conversation.model.attachment + +import androidx.compose.runtime.Immutable + +@Immutable +internal enum class ConversationVCardAttachmentType { + CONTACT, + LOCATION, +} diff --git a/src/com/android/messaging/data/conversation/model/draft/PhotoPickerDraftAttachment.kt b/src/com/android/messaging/data/conversation/model/draft/PhotoPickerDraftAttachment.kt new file mode 100644 index 00000000..2a8e8c8f --- /dev/null +++ b/src/com/android/messaging/data/conversation/model/draft/PhotoPickerDraftAttachment.kt @@ -0,0 +1,6 @@ +package com.android.messaging.data.conversation.model.draft + +internal data class PhotoPickerDraftAttachment( + val sourceContentUri: String, + val draftAttachment: ConversationDraftAttachment, +) diff --git a/src/com/android/messaging/data/conversation/repository/ConversationDraftsRepository.kt b/src/com/android/messaging/data/conversation/repository/ConversationDraftsRepository.kt index c53cf188..41264f7d 100644 --- a/src/com/android/messaging/data/conversation/repository/ConversationDraftsRepository.kt +++ b/src/com/android/messaging/data/conversation/repository/ConversationDraftsRepository.kt @@ -8,6 +8,7 @@ import androidx.core.net.toUri import com.android.messaging.data.conversation.mapper.ConversationDraftMessageDataMapper import com.android.messaging.data.conversation.mapper.ConversationMessageDataDraftMapper import com.android.messaging.data.conversation.model.draft.ConversationDraft +import com.android.messaging.data.conversation.store.ConversationDraftStore import com.android.messaging.datamodel.MessagingContentProvider import com.android.messaging.datamodel.data.MessageData import com.android.messaging.di.core.IoDispatcher diff --git a/src/com/android/messaging/ui/conversation/v2/messages/repository/ConversationVCardMetadataRepository.kt b/src/com/android/messaging/data/conversation/repository/ConversationVCardMetadataRepository.kt similarity index 90% rename from src/com/android/messaging/ui/conversation/v2/messages/repository/ConversationVCardMetadataRepository.kt rename to src/com/android/messaging/data/conversation/repository/ConversationVCardMetadataRepository.kt index 19b72b04..852a4252 100644 --- a/src/com/android/messaging/ui/conversation/v2/messages/repository/ConversationVCardMetadataRepository.kt +++ b/src/com/android/messaging/data/conversation/repository/ConversationVCardMetadataRepository.kt @@ -1,11 +1,12 @@ -package com.android.messaging.ui.conversation.v2.messages.repository +package com.android.messaging.data.conversation.repository import android.content.Context import androidx.core.net.toUri +import com.android.messaging.data.conversation.mapper.ConversationVCardMetadataMapper +import com.android.messaging.data.conversation.model.attachment.ConversationVCardAttachmentMetadata import com.android.messaging.datamodel.DataModel import com.android.messaging.datamodel.data.PersonItemData import com.android.messaging.datamodel.data.VCardContactItemData -import com.android.messaging.ui.conversation.v2.messages.model.attachment.ConversationVCardAttachmentMetadata import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject import kotlinx.coroutines.channels.awaitClose diff --git a/src/com/android/messaging/data/conversation/repository/ConversationDraftStore.kt b/src/com/android/messaging/data/conversation/store/ConversationDraftStore.kt similarity index 96% rename from src/com/android/messaging/data/conversation/repository/ConversationDraftStore.kt rename to src/com/android/messaging/data/conversation/store/ConversationDraftStore.kt index a2501bb2..18729f75 100644 --- a/src/com/android/messaging/data/conversation/repository/ConversationDraftStore.kt +++ b/src/com/android/messaging/data/conversation/store/ConversationDraftStore.kt @@ -1,4 +1,4 @@ -package com.android.messaging.data.conversation.repository +package com.android.messaging.data.conversation.store import com.android.messaging.datamodel.BugleDatabaseOperations import com.android.messaging.datamodel.DataModel diff --git a/src/com/android/messaging/ui/conversation/v2/mediapicker/model/AttachmentToSave.kt b/src/com/android/messaging/data/media/model/AttachmentToSave.kt similarity index 59% rename from src/com/android/messaging/ui/conversation/v2/mediapicker/model/AttachmentToSave.kt rename to src/com/android/messaging/data/media/model/AttachmentToSave.kt index 52e3ba8b..3f7c2732 100644 --- a/src/com/android/messaging/ui/conversation/v2/mediapicker/model/AttachmentToSave.kt +++ b/src/com/android/messaging/data/media/model/AttachmentToSave.kt @@ -1,4 +1,4 @@ -package com.android.messaging.ui.conversation.v2.mediapicker.model +package com.android.messaging.data.media.model internal data class AttachmentToSave( val contentType: String, diff --git a/src/com/android/messaging/ui/conversation/v2/mediapicker/model/ConversationCapturedMedia.kt b/src/com/android/messaging/data/media/model/ConversationCapturedMedia.kt similarity index 70% rename from src/com/android/messaging/ui/conversation/v2/mediapicker/model/ConversationCapturedMedia.kt rename to src/com/android/messaging/data/media/model/ConversationCapturedMedia.kt index e35d3446..da1609aa 100644 --- a/src/com/android/messaging/ui/conversation/v2/mediapicker/model/ConversationCapturedMedia.kt +++ b/src/com/android/messaging/data/media/model/ConversationCapturedMedia.kt @@ -1,4 +1,4 @@ -package com.android.messaging.ui.conversation.v2.mediapicker.model +package com.android.messaging.data.media.model internal data class ConversationCapturedMedia( val contentUri: String, diff --git a/src/com/android/messaging/ui/conversation/v2/mediapicker/model/PhotoPickerDraftAttachmentResult.kt b/src/com/android/messaging/data/media/model/PhotoPickerDraftAttachmentResult.kt similarity index 69% rename from src/com/android/messaging/ui/conversation/v2/mediapicker/model/PhotoPickerDraftAttachmentResult.kt rename to src/com/android/messaging/data/media/model/PhotoPickerDraftAttachmentResult.kt index 1a00bac8..e1fc2e0c 100644 --- a/src/com/android/messaging/ui/conversation/v2/mediapicker/model/PhotoPickerDraftAttachmentResult.kt +++ b/src/com/android/messaging/data/media/model/PhotoPickerDraftAttachmentResult.kt @@ -1,4 +1,6 @@ -package com.android.messaging.ui.conversation.v2.mediapicker.model +package com.android.messaging.data.media.model + +import com.android.messaging.data.conversation.model.draft.PhotoPickerDraftAttachment internal sealed interface PhotoPickerDraftAttachmentResult { data class Resolved( diff --git a/src/com/android/messaging/ui/conversation/v2/mediapicker/repository/SaveAttachmentsResult.kt b/src/com/android/messaging/data/media/model/SaveAttachmentsResult.kt similarity index 66% rename from src/com/android/messaging/ui/conversation/v2/mediapicker/repository/SaveAttachmentsResult.kt rename to src/com/android/messaging/data/media/model/SaveAttachmentsResult.kt index 37d987cf..305c42d9 100644 --- a/src/com/android/messaging/ui/conversation/v2/mediapicker/repository/SaveAttachmentsResult.kt +++ b/src/com/android/messaging/data/media/model/SaveAttachmentsResult.kt @@ -1,4 +1,4 @@ -package com.android.messaging.ui.conversation.v2.mediapicker.repository +package com.android.messaging.data.media.model internal data class SaveAttachmentsResult( val imageCount: Int, diff --git a/src/com/android/messaging/ui/conversation/v2/mediapicker/repository/ConversationAttachmentRepository.kt b/src/com/android/messaging/data/media/repository/ConversationAttachmentRepository.kt similarity index 98% rename from src/com/android/messaging/ui/conversation/v2/mediapicker/repository/ConversationAttachmentRepository.kt rename to src/com/android/messaging/data/media/repository/ConversationAttachmentRepository.kt index 512bcdfe..e7c803b5 100644 --- a/src/com/android/messaging/ui/conversation/v2/mediapicker/repository/ConversationAttachmentRepository.kt +++ b/src/com/android/messaging/data/media/repository/ConversationAttachmentRepository.kt @@ -1,4 +1,4 @@ -package com.android.messaging.ui.conversation.v2.mediapicker.repository +package com.android.messaging.data.media.repository import android.content.ContentResolver import android.content.ContentValues @@ -12,11 +12,12 @@ import android.webkit.MimeTypeMap import androidx.core.database.getStringOrNull import androidx.core.net.toUri import com.android.messaging.data.conversation.model.draft.ConversationDraftAttachment +import com.android.messaging.data.conversation.model.draft.PhotoPickerDraftAttachment +import com.android.messaging.data.media.model.AttachmentToSave +import com.android.messaging.data.media.model.PhotoPickerDraftAttachmentResult +import com.android.messaging.data.media.model.SaveAttachmentsResult import com.android.messaging.datamodel.MediaScratchFileProvider import com.android.messaging.di.core.IoDispatcher -import com.android.messaging.ui.conversation.v2.mediapicker.model.AttachmentToSave -import com.android.messaging.ui.conversation.v2.mediapicker.model.PhotoPickerDraftAttachment -import com.android.messaging.ui.conversation.v2.mediapicker.model.PhotoPickerDraftAttachmentResult import com.android.messaging.util.ContentType import com.android.messaging.util.LogUtil import com.android.messaging.util.core.extension.typedFlow diff --git a/src/com/android/messaging/di/conversation/ConversationBindsModule.kt b/src/com/android/messaging/di/conversation/ConversationBindsModule.kt index 28efcaaa..72800218 100644 --- a/src/com/android/messaging/di/conversation/ConversationBindsModule.kt +++ b/src/com/android/messaging/di/conversation/ConversationBindsModule.kt @@ -4,8 +4,8 @@ import com.android.messaging.data.conversation.mapper.ConversationDraftMessageDa import com.android.messaging.data.conversation.mapper.ConversationDraftMessageDataMapperImpl import com.android.messaging.data.conversation.mapper.ConversationMessageDataDraftMapper import com.android.messaging.data.conversation.mapper.ConversationMessageDataDraftMapperImpl -import com.android.messaging.data.conversation.repository.ConversationDraftStore -import com.android.messaging.data.conversation.repository.ConversationDraftStoreImpl +import com.android.messaging.data.conversation.mapper.ConversationVCardMetadataMapper +import com.android.messaging.data.conversation.mapper.ConversationVCardMetadataMapperImpl import com.android.messaging.data.conversation.repository.ConversationDraftsRepository import com.android.messaging.data.conversation.repository.ConversationDraftsRepositoryImpl import com.android.messaging.data.conversation.repository.ConversationParticipantsRepository @@ -14,8 +14,14 @@ import com.android.messaging.data.conversation.repository.ConversationRecipients import com.android.messaging.data.conversation.repository.ConversationRecipientsRepositoryImpl import com.android.messaging.data.conversation.repository.ConversationSubscriptionsRepository import com.android.messaging.data.conversation.repository.ConversationSubscriptionsRepositoryImpl +import com.android.messaging.data.conversation.repository.ConversationVCardMetadataRepository +import com.android.messaging.data.conversation.repository.ConversationVCardMetadataRepositoryImpl import com.android.messaging.data.conversation.repository.ConversationsRepository import com.android.messaging.data.conversation.repository.ConversationsRepositoryImpl +import com.android.messaging.data.conversation.store.ConversationDraftStore +import com.android.messaging.data.conversation.store.ConversationDraftStoreImpl +import com.android.messaging.data.media.repository.ConversationAttachmentRepository +import com.android.messaging.data.media.repository.ConversationAttachmentRepositoryImpl import com.android.messaging.data.media.repository.ConversationMediaRepository import com.android.messaging.data.media.repository.ConversationMediaRepositoryImpl import com.android.messaging.domain.contacts.usecase.IsReadContactsPermissionGranted @@ -42,22 +48,16 @@ import com.android.messaging.domain.conversation.usecase.telephony.IsDeviceVoice import com.android.messaging.domain.conversation.usecase.telephony.IsDeviceVoiceCapableImpl import com.android.messaging.domain.conversation.usecase.telephony.IsEmergencyPhoneNumber import com.android.messaging.domain.conversation.usecase.telephony.IsEmergencyPhoneNumberImpl +import com.android.messaging.ui.conversation.v2.attachment.mapper.ConversationVCardAttachmentUiModelMapper +import com.android.messaging.ui.conversation.v2.attachment.mapper.ConversationVCardAttachmentUiModelMapperImpl import com.android.messaging.ui.conversation.v2.composer.mapper.ConversationComposerAttachmentUiModelMapper import com.android.messaging.ui.conversation.v2.composer.mapper.ConversationComposerAttachmentUiModelMapperImpl import com.android.messaging.ui.conversation.v2.composer.mapper.ConversationComposerUiStateMapper import com.android.messaging.ui.conversation.v2.composer.mapper.ConversationComposerUiStateMapperImpl import com.android.messaging.ui.conversation.v2.mediapicker.mapper.ConversationDraftAttachmentMapper import com.android.messaging.ui.conversation.v2.mediapicker.mapper.ConversationDraftAttachmentMapperImpl -import com.android.messaging.ui.conversation.v2.mediapicker.repository.ConversationAttachmentRepository -import com.android.messaging.ui.conversation.v2.mediapicker.repository.ConversationAttachmentRepositoryImpl import com.android.messaging.ui.conversation.v2.messages.mapper.ConversationMessageUiModelMapper import com.android.messaging.ui.conversation.v2.messages.mapper.ConversationMessageUiModelMapperImpl -import com.android.messaging.ui.conversation.v2.messages.mapper.ConversationVCardAttachmentUiModelMapper -import com.android.messaging.ui.conversation.v2.messages.mapper.ConversationVCardAttachmentUiModelMapperImpl -import com.android.messaging.ui.conversation.v2.messages.repository.ConversationVCardMetadataMapper -import com.android.messaging.ui.conversation.v2.messages.repository.ConversationVCardMetadataMapperImpl -import com.android.messaging.ui.conversation.v2.messages.repository.ConversationVCardMetadataRepository -import com.android.messaging.ui.conversation.v2.messages.repository.ConversationVCardMetadataRepositoryImpl import com.android.messaging.ui.conversation.v2.metadata.mapper.ConversationMetadataUiStateMapper import com.android.messaging.ui.conversation.v2.metadata.mapper.ConversationMetadataUiStateMapperImpl import dagger.Binds diff --git a/src/com/android/messaging/di/conversation/ConversationViewModelBindsModule.kt b/src/com/android/messaging/di/conversation/ConversationViewModelBindsModule.kt index c23696af..73b78947 100644 --- a/src/com/android/messaging/di/conversation/ConversationViewModelBindsModule.kt +++ b/src/com/android/messaging/di/conversation/ConversationViewModelBindsModule.kt @@ -8,8 +8,8 @@ import com.android.messaging.ui.conversation.v2.composer.delegate.ConversationDr import com.android.messaging.ui.conversation.v2.composer.delegate.ConversationDraftDelegateImpl import com.android.messaging.ui.conversation.v2.focus.delegate.ConversationFocusDelegate import com.android.messaging.ui.conversation.v2.focus.delegate.ConversationFocusDelegateImpl -import com.android.messaging.ui.conversation.v2.mediapicker.ConversationMediaPickerDelegate -import com.android.messaging.ui.conversation.v2.mediapicker.ConversationMediaPickerDelegateImpl +import com.android.messaging.ui.conversation.v2.mediapicker.delegate.ConversationMediaPickerDelegate +import com.android.messaging.ui.conversation.v2.mediapicker.delegate.ConversationMediaPickerDelegateImpl import com.android.messaging.ui.conversation.v2.messages.delegate.ConversationMessageSelectionDelegate import com.android.messaging.ui.conversation.v2.messages.delegate.ConversationMessageSelectionDelegateImpl import com.android.messaging.ui.conversation.v2.messages.delegate.ConversationMessagesDelegate diff --git a/src/com/android/messaging/ui/conversation/v2/messages/mapper/ConversationVCardAttachmentUiModelMapper.kt b/src/com/android/messaging/ui/conversation/v2/attachment/mapper/ConversationVCardAttachmentUiModelMapper.kt similarity index 92% rename from src/com/android/messaging/ui/conversation/v2/messages/mapper/ConversationVCardAttachmentUiModelMapper.kt rename to src/com/android/messaging/ui/conversation/v2/attachment/mapper/ConversationVCardAttachmentUiModelMapper.kt index fddaa8c7..01d477b4 100644 --- a/src/com/android/messaging/ui/conversation/v2/messages/mapper/ConversationVCardAttachmentUiModelMapper.kt +++ b/src/com/android/messaging/ui/conversation/v2/attachment/mapper/ConversationVCardAttachmentUiModelMapper.kt @@ -1,9 +1,9 @@ -package com.android.messaging.ui.conversation.v2.messages.mapper +package com.android.messaging.ui.conversation.v2.attachment.mapper import com.android.messaging.R -import com.android.messaging.ui.conversation.v2.messages.model.attachment.ConversationVCardAttachmentMetadata -import com.android.messaging.ui.conversation.v2.messages.model.attachment.ConversationVCardAttachmentType -import com.android.messaging.ui.conversation.v2.messages.model.attachment.ConversationVCardAttachmentUiModel +import com.android.messaging.data.conversation.model.attachment.ConversationVCardAttachmentMetadata +import com.android.messaging.data.conversation.model.attachment.ConversationVCardAttachmentType +import com.android.messaging.ui.conversation.v2.attachment.model.ConversationVCardAttachmentUiModel import javax.inject.Inject internal interface ConversationVCardAttachmentUiModelMapper { diff --git a/src/com/android/messaging/ui/conversation/v2/messages/model/attachment/ConversationVCardAttachmentUiModel.kt b/src/com/android/messaging/ui/conversation/v2/attachment/model/ConversationVCardAttachmentUiModel.kt similarity index 64% rename from src/com/android/messaging/ui/conversation/v2/messages/model/attachment/ConversationVCardAttachmentUiModel.kt rename to src/com/android/messaging/ui/conversation/v2/attachment/model/ConversationVCardAttachmentUiModel.kt index b6ea498a..c202f87d 100644 --- a/src/com/android/messaging/ui/conversation/v2/messages/model/attachment/ConversationVCardAttachmentUiModel.kt +++ b/src/com/android/messaging/ui/conversation/v2/attachment/model/ConversationVCardAttachmentUiModel.kt @@ -1,6 +1,7 @@ -package com.android.messaging.ui.conversation.v2.messages.model.attachment +package com.android.messaging.ui.conversation.v2.attachment.model import androidx.compose.runtime.Immutable +import com.android.messaging.data.conversation.model.attachment.ConversationVCardAttachmentType @Immutable internal data class ConversationVCardAttachmentUiModel( @@ -10,9 +11,3 @@ internal data class ConversationVCardAttachmentUiModel( val subtitleText: String? = null, val subtitleTextResId: Int? = null, ) - -@Immutable -internal enum class ConversationVCardAttachmentType { - CONTACT, - LOCATION, -} diff --git a/src/com/android/messaging/ui/conversation/v2/mediapicker/component/ConversationMediaThumbnail.kt b/src/com/android/messaging/ui/conversation/v2/attachment/ui/ConversationMediaThumbnail.kt similarity index 99% rename from src/com/android/messaging/ui/conversation/v2/mediapicker/component/ConversationMediaThumbnail.kt rename to src/com/android/messaging/ui/conversation/v2/attachment/ui/ConversationMediaThumbnail.kt index 14558cf2..4373869e 100644 --- a/src/com/android/messaging/ui/conversation/v2/mediapicker/component/ConversationMediaThumbnail.kt +++ b/src/com/android/messaging/ui/conversation/v2/attachment/ui/ConversationMediaThumbnail.kt @@ -1,4 +1,4 @@ -package com.android.messaging.ui.conversation.v2.mediapicker.component +package com.android.messaging.ui.conversation.v2.attachment.ui import android.content.Context import android.graphics.Bitmap diff --git a/src/com/android/messaging/ui/conversation/v2/mediapicker/component/ConversationMediaThumbnailBitmapLoader.kt b/src/com/android/messaging/ui/conversation/v2/attachment/ui/ConversationMediaThumbnailBitmapLoader.kt similarity index 99% rename from src/com/android/messaging/ui/conversation/v2/mediapicker/component/ConversationMediaThumbnailBitmapLoader.kt rename to src/com/android/messaging/ui/conversation/v2/attachment/ui/ConversationMediaThumbnailBitmapLoader.kt index 30495d98..d35b35e6 100644 --- a/src/com/android/messaging/ui/conversation/v2/mediapicker/component/ConversationMediaThumbnailBitmapLoader.kt +++ b/src/com/android/messaging/ui/conversation/v2/attachment/ui/ConversationMediaThumbnailBitmapLoader.kt @@ -1,4 +1,4 @@ -package com.android.messaging.ui.conversation.v2.mediapicker.component +package com.android.messaging.ui.conversation.v2.attachment.ui import android.content.ContentResolver import android.graphics.Bitmap diff --git a/src/com/android/messaging/ui/conversation/v2/attachment/ui/ConversationVCardAttachmentCardContent.kt b/src/com/android/messaging/ui/conversation/v2/attachment/ui/ConversationVCardAttachmentCardContent.kt new file mode 100644 index 00000000..660cc221 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/v2/attachment/ui/ConversationVCardAttachmentCardContent.kt @@ -0,0 +1,98 @@ +package com.android.messaging.ui.conversation.v2.attachment.ui + +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.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Person +import androidx.compose.material.icons.rounded.Place +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.android.messaging.data.conversation.model.attachment.ConversationVCardAttachmentType + +@Composable +internal fun ConversationVCardAttachmentCardContent( + modifier: Modifier = Modifier, + type: ConversationVCardAttachmentType, + titleText: String?, + titleTextResId: Int?, + subtitleText: String?, + subtitleTextResId: Int?, +) { + val title = resolveTitleText( + titleText = titleText, + titleTextResId = titleTextResId, + ) + + val subtitle = resolveSubtitleText( + subtitleText = subtitleText, + subtitleTextResId = subtitleTextResId, + ) + + Row( + modifier = modifier, + horizontalArrangement = Arrangement.spacedBy(space = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier.size(size = 28.dp), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = when (type) { + ConversationVCardAttachmentType.CONTACT -> Icons.Rounded.Person + ConversationVCardAttachmentType.LOCATION -> Icons.Rounded.Place + }, + contentDescription = null, + ) + } + + Column( + verticalArrangement = Arrangement.spacedBy(space = 2.dp), + ) { + Text( + text = title, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + + subtitle?.let { subtitleText -> + Text( + text = subtitleText, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} + +@Composable +private fun resolveTitleText( + titleText: String?, + titleTextResId: Int?, +): String { + return titleText + ?: titleTextResId?.let { titleResId -> + stringResource(titleResId) + } + .orEmpty() +} + +@Composable +private fun resolveSubtitleText( + subtitleText: String?, + subtitleTextResId: Int?, +): String? { + return subtitleText ?: subtitleTextResId?.let { subtitleResId -> + stringResource(subtitleResId) + } +} diff --git a/src/com/android/messaging/ui/conversation/v2/audio/delegate/ConversationAudioRecordingDelegate.kt b/src/com/android/messaging/ui/conversation/v2/audio/delegate/ConversationAudioRecordingDelegate.kt index 800fbd39..bdcd78c4 100644 --- a/src/com/android/messaging/ui/conversation/v2/audio/delegate/ConversationAudioRecordingDelegate.kt +++ b/src/com/android/messaging/ui/conversation/v2/audio/delegate/ConversationAudioRecordingDelegate.kt @@ -6,12 +6,12 @@ import com.android.messaging.data.conversation.model.draft.ConversationDraftAtta import com.android.messaging.data.conversation.model.draft.ConversationDraftPendingAttachment import com.android.messaging.data.conversation.model.draft.ConversationDraftPendingAttachmentKind import com.android.messaging.data.conversation.repository.ConversationSubscriptionsRepository +import com.android.messaging.data.media.repository.ConversationAttachmentRepository import com.android.messaging.di.core.DefaultDispatcher import com.android.messaging.ui.conversation.v2.audio.model.ConversationAudioRecordingPhase import com.android.messaging.ui.conversation.v2.audio.model.ConversationAudioRecordingUiState import com.android.messaging.ui.conversation.v2.common.ConversationScreenDelegate import com.android.messaging.ui.conversation.v2.composer.delegate.ConversationDraftDelegate -import com.android.messaging.ui.conversation.v2.mediapicker.repository.ConversationAttachmentRepository import com.android.messaging.ui.mediapicker.LevelTrackingMediaRecorder import com.android.messaging.util.ContentType import com.android.messaging.util.LogUtil diff --git a/src/com/android/messaging/ui/conversation/v2/composer/delegate/ConversationComposerAttachmentsDelegate.kt b/src/com/android/messaging/ui/conversation/v2/composer/delegate/ConversationComposerAttachmentsDelegate.kt index 80f4c680..2e360f19 100644 --- a/src/com/android/messaging/ui/conversation/v2/composer/delegate/ConversationComposerAttachmentsDelegate.kt +++ b/src/com/android/messaging/ui/conversation/v2/composer/delegate/ConversationComposerAttachmentsDelegate.kt @@ -1,14 +1,14 @@ package com.android.messaging.ui.conversation.v2.composer.delegate +import com.android.messaging.data.conversation.model.attachment.ConversationVCardAttachmentMetadata import com.android.messaging.data.conversation.model.draft.ConversationDraftAttachment import com.android.messaging.data.conversation.model.draft.ConversationDraftPendingAttachment +import com.android.messaging.data.conversation.repository.ConversationVCardMetadataRepository import com.android.messaging.di.core.DefaultDispatcher +import com.android.messaging.ui.conversation.v2.attachment.mapper.ConversationVCardAttachmentUiModelMapper import com.android.messaging.ui.conversation.v2.composer.mapper.ConversationComposerAttachmentUiModelMapper import com.android.messaging.ui.conversation.v2.composer.model.ComposerAttachmentUiModel import com.android.messaging.ui.conversation.v2.composer.model.ConversationDraftState -import com.android.messaging.ui.conversation.v2.messages.mapper.ConversationVCardAttachmentUiModelMapper -import com.android.messaging.ui.conversation.v2.messages.model.attachment.ConversationVCardAttachmentMetadata -import com.android.messaging.ui.conversation.v2.messages.repository.ConversationVCardMetadataRepository import javax.inject.Inject import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf diff --git a/src/com/android/messaging/ui/conversation/v2/composer/mapper/ConversationComposerAttachmentUiModelMapper.kt b/src/com/android/messaging/ui/conversation/v2/composer/mapper/ConversationComposerAttachmentUiModelMapper.kt index 33173759..9ef02f1d 100644 --- a/src/com/android/messaging/ui/conversation/v2/composer/mapper/ConversationComposerAttachmentUiModelMapper.kt +++ b/src/com/android/messaging/ui/conversation/v2/composer/mapper/ConversationComposerAttachmentUiModelMapper.kt @@ -1,11 +1,11 @@ package com.android.messaging.ui.conversation.v2.composer.mapper +import com.android.messaging.data.conversation.model.attachment.ConversationVCardAttachmentMetadata import com.android.messaging.data.conversation.model.draft.ConversationDraftAttachment import com.android.messaging.data.conversation.model.draft.ConversationDraftPendingAttachment import com.android.messaging.data.conversation.model.draft.ConversationDraftPendingAttachmentKind +import com.android.messaging.ui.conversation.v2.attachment.mapper.ConversationVCardAttachmentUiModelMapper import com.android.messaging.ui.conversation.v2.composer.model.ComposerAttachmentUiModel -import com.android.messaging.ui.conversation.v2.messages.mapper.ConversationVCardAttachmentUiModelMapper -import com.android.messaging.ui.conversation.v2.messages.model.attachment.ConversationVCardAttachmentMetadata import com.android.messaging.util.ContentType import javax.inject.Inject import kotlinx.collections.immutable.ImmutableList diff --git a/src/com/android/messaging/ui/conversation/v2/composer/model/ComposerAttachmentUiModel.kt b/src/com/android/messaging/ui/conversation/v2/composer/model/ComposerAttachmentUiModel.kt index c2df31db..73b038fb 100644 --- a/src/com/android/messaging/ui/conversation/v2/composer/model/ComposerAttachmentUiModel.kt +++ b/src/com/android/messaging/ui/conversation/v2/composer/model/ComposerAttachmentUiModel.kt @@ -1,7 +1,7 @@ package com.android.messaging.ui.conversation.v2.composer.model import androidx.compose.runtime.Immutable -import com.android.messaging.ui.conversation.v2.messages.model.attachment.ConversationVCardAttachmentUiModel +import com.android.messaging.ui.conversation.v2.attachment.model.ConversationVCardAttachmentUiModel @Immutable internal sealed interface ComposerAttachmentUiModel { diff --git a/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationAttachmentPreview.kt b/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationAttachmentPreview.kt index 0a685f45..ba7684e2 100644 --- a/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationAttachmentPreview.kt +++ b/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationAttachmentPreview.kt @@ -39,13 +39,13 @@ import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import com.android.messaging.R import com.android.messaging.ui.conversation.v2.CONVERSATION_ATTACHMENT_PREVIEW_LIST_TEST_TAG +import com.android.messaging.ui.conversation.v2.attachment.model.ConversationVCardAttachmentUiModel +import com.android.messaging.ui.conversation.v2.attachment.ui.ConversationMediaThumbnail +import com.android.messaging.ui.conversation.v2.attachment.ui.ConversationVCardAttachmentCardContent import com.android.messaging.ui.conversation.v2.audio.formatConversationAudioDuration import com.android.messaging.ui.conversation.v2.composer.model.ComposerAttachmentUiModel import com.android.messaging.ui.conversation.v2.conversationAttachmentPreviewItemTestTag import com.android.messaging.ui.conversation.v2.conversationAttachmentPreviewRemoveButtonTestTag -import com.android.messaging.ui.conversation.v2.mediapicker.component.ConversationMediaThumbnail -import com.android.messaging.ui.conversation.v2.messages.model.attachment.ConversationVCardAttachmentUiModel -import com.android.messaging.ui.conversation.v2.messages.ui.attachment.ConversationVCardAttachmentCardContent import kotlinx.collections.immutable.ImmutableList private val ATTACHMENT_PREVIEW_CORNER_RADIUS = 20.dp diff --git a/src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationMediaPicker.kt b/src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationMediaPicker.kt index 17bf4deb..b50057cb 100644 --- a/src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationMediaPicker.kt +++ b/src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationMediaPicker.kt @@ -27,11 +27,11 @@ import androidx.photopicker.compose.EmbeddedPhotoPicker import androidx.photopicker.compose.EmbeddedPhotoPickerState import androidx.photopicker.compose.ExperimentalPhotoPickerComposeApi import androidx.photopicker.compose.rememberEmbeddedPhotoPickerState +import com.android.messaging.data.media.model.ConversationCapturedMedia import com.android.messaging.ui.conversation.v2.composer.model.ComposerAttachmentUiModel import com.android.messaging.ui.conversation.v2.mediapicker.camera.BindConversationCameraLifecycleEffect import com.android.messaging.ui.conversation.v2.mediapicker.camera.ConversationCameraController import com.android.messaging.ui.conversation.v2.mediapicker.camera.rememberConversationCameraController -import com.android.messaging.ui.conversation.v2.mediapicker.model.ConversationCapturedMedia import com.android.messaging.util.ContentType import com.android.messaging.util.LogUtil import kotlinx.collections.immutable.ImmutableList diff --git a/src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationMediaPickerCaptureRoute.kt b/src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationMediaPickerCaptureRoute.kt index 73817fb3..9e4db6aa 100644 --- a/src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationMediaPickerCaptureRoute.kt +++ b/src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationMediaPickerCaptureRoute.kt @@ -3,13 +3,13 @@ package com.android.messaging.ui.conversation.v2.mediapicker import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.android.messaging.data.media.model.ConversationCapturedMedia import com.android.messaging.ui.conversation.v2.mediapicker.camera.ConversationCameraController import com.android.messaging.ui.conversation.v2.mediapicker.camera.handlePhotoCaptureRequest import com.android.messaging.ui.conversation.v2.mediapicker.camera.handleSwitchCameraRequest import com.android.messaging.ui.conversation.v2.mediapicker.camera.handleToggleFlashRequest import com.android.messaging.ui.conversation.v2.mediapicker.camera.handleVideoCaptureRequest import com.android.messaging.ui.conversation.v2.mediapicker.component.capture.ConversationMediaCaptureContent -import com.android.messaging.ui.conversation.v2.mediapicker.model.ConversationCapturedMedia @Composable internal fun ConversationMediaCaptureRoute( diff --git a/src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationMediaPickerCaptureScene.kt b/src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationMediaPickerCaptureScene.kt index a9c9ab49..4d6415ce 100644 --- a/src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationMediaPickerCaptureScene.kt +++ b/src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationMediaPickerCaptureScene.kt @@ -7,9 +7,9 @@ import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.android.messaging.data.media.model.ConversationCapturedMedia import com.android.messaging.ui.conversation.v2.mediapicker.camera.ConversationCameraController import com.android.messaging.ui.conversation.v2.mediapicker.component.capture.ConversationMediaCameraPreviewSurface -import com.android.messaging.ui.conversation.v2.mediapicker.model.ConversationCapturedMedia @Composable internal fun ConversationMediaPickerCaptureScene( diff --git a/src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationMediaPickerOverlay.kt b/src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationMediaPickerOverlay.kt index b9f83991..e0da4cd6 100644 --- a/src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationMediaPickerOverlay.kt +++ b/src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationMediaPickerOverlay.kt @@ -14,9 +14,9 @@ import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.testTag +import com.android.messaging.data.media.model.ConversationCapturedMedia import com.android.messaging.ui.conversation.v2.CONVERSATION_MEDIA_PICKER_OVERLAY_TEST_TAG import com.android.messaging.ui.conversation.v2.composer.model.ComposerAttachmentUiModel -import com.android.messaging.ui.conversation.v2.mediapicker.model.ConversationCapturedMedia import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableMap diff --git a/src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationMediaPickerScaffold.kt b/src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationMediaPickerScaffold.kt index 3d2006b6..d03c9505 100644 --- a/src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationMediaPickerScaffold.kt +++ b/src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationMediaPickerScaffold.kt @@ -16,10 +16,10 @@ import androidx.compose.material3.BottomSheetScaffoldState import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import com.android.messaging.data.media.model.ConversationCapturedMedia import com.android.messaging.ui.conversation.v2.composer.model.ComposerAttachmentUiModel import com.android.messaging.ui.conversation.v2.mediapicker.camera.ConversationCameraController import com.android.messaging.ui.conversation.v2.mediapicker.component.review.ConversationMediaReviewScene -import com.android.messaging.ui.conversation.v2.mediapicker.model.ConversationCapturedMedia import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableMap diff --git a/src/com/android/messaging/ui/conversation/v2/mediapicker/camera/ConversationCameraController.kt b/src/com/android/messaging/ui/conversation/v2/mediapicker/camera/ConversationCameraController.kt index f23010b4..4c22ac29 100644 --- a/src/com/android/messaging/ui/conversation/v2/mediapicker/camera/ConversationCameraController.kt +++ b/src/com/android/messaging/ui/conversation/v2/mediapicker/camera/ConversationCameraController.kt @@ -22,8 +22,8 @@ import androidx.compose.runtime.remember import androidx.compose.ui.platform.LocalContext import androidx.core.content.ContextCompat import androidx.lifecycle.LifecycleOwner +import com.android.messaging.data.media.model.ConversationCapturedMedia import com.android.messaging.datamodel.MediaScratchFileProvider -import com.android.messaging.ui.conversation.v2.mediapicker.model.ConversationCapturedMedia import com.android.messaging.util.ContentType import com.google.common.util.concurrent.ListenableFuture import java.io.File diff --git a/src/com/android/messaging/ui/conversation/v2/mediapicker/camera/ConversationMediaPickerActions.kt b/src/com/android/messaging/ui/conversation/v2/mediapicker/camera/ConversationMediaPickerActions.kt index 68f82e9c..d5bde94f 100644 --- a/src/com/android/messaging/ui/conversation/v2/mediapicker/camera/ConversationMediaPickerActions.kt +++ b/src/com/android/messaging/ui/conversation/v2/mediapicker/camera/ConversationMediaPickerActions.kt @@ -1,7 +1,7 @@ package com.android.messaging.ui.conversation.v2.mediapicker.camera import com.android.messaging.R -import com.android.messaging.ui.conversation.v2.mediapicker.model.ConversationCapturedMedia +import com.android.messaging.data.media.model.ConversationCapturedMedia import com.android.messaging.util.UiUtils internal fun handlePhotoCaptureRequest( diff --git a/src/com/android/messaging/ui/conversation/v2/mediapicker/component/review/ConversationMediaReviewBackground.kt b/src/com/android/messaging/ui/conversation/v2/mediapicker/component/review/ConversationMediaReviewBackground.kt index 43b9659a..d579b00d 100644 --- a/src/com/android/messaging/ui/conversation/v2/mediapicker/component/review/ConversationMediaReviewBackground.kt +++ b/src/com/android/messaging/ui/conversation/v2/mediapicker/component/review/ConversationMediaReviewBackground.kt @@ -19,8 +19,8 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.IntSize import androidx.core.net.toUri +import com.android.messaging.ui.conversation.v2.attachment.ui.loadConversationMediaThumbnailBitmap import com.android.messaging.ui.conversation.v2.composer.model.ComposerAttachmentUiModel -import com.android.messaging.ui.conversation.v2.mediapicker.component.loadConversationMediaThumbnailBitmap import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList diff --git a/src/com/android/messaging/ui/conversation/v2/mediapicker/component/review/ConversationMediaReviewPageCard.kt b/src/com/android/messaging/ui/conversation/v2/mediapicker/component/review/ConversationMediaReviewPageCard.kt index 5ea7ddd3..04a63545 100644 --- a/src/com/android/messaging/ui/conversation/v2/mediapicker/component/review/ConversationMediaReviewPageCard.kt +++ b/src/com/android/messaging/ui/conversation/v2/mediapicker/component/review/ConversationMediaReviewPageCard.kt @@ -39,8 +39,8 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.util.lerp import androidx.compose.ui.zIndex import com.android.messaging.R +import com.android.messaging.ui.conversation.v2.attachment.ui.ConversationMediaThumbnail import com.android.messaging.ui.conversation.v2.composer.model.ComposerAttachmentUiModel -import com.android.messaging.ui.conversation.v2.mediapicker.component.ConversationMediaThumbnail import com.android.messaging.ui.conversation.v2.mediapicker.component.PickerOverlayBackgroundButton import kotlin.math.absoluteValue import kotlinx.collections.immutable.ImmutableList diff --git a/src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationMediaPickerDelegate.kt b/src/com/android/messaging/ui/conversation/v2/mediapicker/delegate/ConversationMediaPickerDelegate.kt similarity index 96% rename from src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationMediaPickerDelegate.kt rename to src/com/android/messaging/ui/conversation/v2/mediapicker/delegate/ConversationMediaPickerDelegate.kt index 19e92400..bd4bb02e 100644 --- a/src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationMediaPickerDelegate.kt +++ b/src/com/android/messaging/ui/conversation/v2/mediapicker/delegate/ConversationMediaPickerDelegate.kt @@ -1,13 +1,13 @@ -package com.android.messaging.ui.conversation.v2.mediapicker +package com.android.messaging.ui.conversation.v2.mediapicker.delegate import com.android.messaging.R +import com.android.messaging.data.conversation.model.draft.PhotoPickerDraftAttachment +import com.android.messaging.data.media.model.ConversationCapturedMedia +import com.android.messaging.data.media.model.PhotoPickerDraftAttachmentResult +import com.android.messaging.data.media.repository.ConversationAttachmentRepository import com.android.messaging.di.core.DefaultDispatcher import com.android.messaging.ui.conversation.v2.composer.delegate.ConversationDraftDelegate import com.android.messaging.ui.conversation.v2.mediapicker.mapper.ConversationDraftAttachmentMapper -import com.android.messaging.ui.conversation.v2.mediapicker.model.ConversationCapturedMedia -import com.android.messaging.ui.conversation.v2.mediapicker.model.PhotoPickerDraftAttachment -import com.android.messaging.ui.conversation.v2.mediapicker.model.PhotoPickerDraftAttachmentResult -import com.android.messaging.ui.conversation.v2.mediapicker.repository.ConversationAttachmentRepository import com.android.messaging.ui.conversation.v2.screen.model.ConversationScreenEffect import com.android.messaging.util.LogUtil import javax.inject.Inject diff --git a/src/com/android/messaging/ui/conversation/v2/mediapicker/mapper/ConversationDraftAttachmentMapper.kt b/src/com/android/messaging/ui/conversation/v2/mediapicker/mapper/ConversationDraftAttachmentMapper.kt index fedf4938..95ad9501 100644 --- a/src/com/android/messaging/ui/conversation/v2/mediapicker/mapper/ConversationDraftAttachmentMapper.kt +++ b/src/com/android/messaging/ui/conversation/v2/mediapicker/mapper/ConversationDraftAttachmentMapper.kt @@ -1,8 +1,8 @@ package com.android.messaging.ui.conversation.v2.mediapicker.mapper import com.android.messaging.data.conversation.model.draft.ConversationDraftAttachment +import com.android.messaging.data.media.model.ConversationCapturedMedia import com.android.messaging.data.media.model.ConversationMediaItem -import com.android.messaging.ui.conversation.v2.mediapicker.model.ConversationCapturedMedia import javax.inject.Inject internal interface ConversationDraftAttachmentMapper { diff --git a/src/com/android/messaging/ui/conversation/v2/mediapicker/model/PhotoPickerDraftAttachment.kt b/src/com/android/messaging/ui/conversation/v2/mediapicker/model/PhotoPickerDraftAttachment.kt deleted file mode 100644 index c05f61f0..00000000 --- a/src/com/android/messaging/ui/conversation/v2/mediapicker/model/PhotoPickerDraftAttachment.kt +++ /dev/null @@ -1,8 +0,0 @@ -package com.android.messaging.ui.conversation.v2.mediapicker.model - -import com.android.messaging.data.conversation.model.draft.ConversationDraftAttachment - -internal data class PhotoPickerDraftAttachment( - val sourceContentUri: String, - val draftAttachment: ConversationDraftAttachment, -) diff --git a/src/com/android/messaging/ui/conversation/v2/messages/delegate/ConversationMessageSelectionDelegate.kt b/src/com/android/messaging/ui/conversation/v2/messages/delegate/ConversationMessageSelectionDelegate.kt index 8178e3a5..938b5088 100644 --- a/src/com/android/messaging/ui/conversation/v2/messages/delegate/ConversationMessageSelectionDelegate.kt +++ b/src/com/android/messaging/ui/conversation/v2/messages/delegate/ConversationMessageSelectionDelegate.kt @@ -5,13 +5,13 @@ import android.content.ClipData import android.content.ClipboardManager import com.android.messaging.R import com.android.messaging.data.conversation.repository.ConversationsRepository +import com.android.messaging.data.media.model.AttachmentToSave +import com.android.messaging.data.media.repository.ConversationAttachmentRepository 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.domain.conversation.usecase.forward.CreateForwardedMessage import com.android.messaging.ui.conversation.v2.common.ConversationScreenDelegate -import com.android.messaging.ui.conversation.v2.mediapicker.model.AttachmentToSave -import com.android.messaging.ui.conversation.v2.mediapicker.repository.ConversationAttachmentRepository import com.android.messaging.ui.conversation.v2.messages.model.message.ConversationMessagePartUiModel import com.android.messaging.ui.conversation.v2.messages.model.message.ConversationMessageUiModel import com.android.messaging.ui.conversation.v2.messages.model.message.ConversationMessagesUiState diff --git a/src/com/android/messaging/ui/conversation/v2/messages/delegate/ConversationMessagesDelegate.kt b/src/com/android/messaging/ui/conversation/v2/messages/delegate/ConversationMessagesDelegate.kt index 24e04dc3..d2bf7ca8 100644 --- a/src/com/android/messaging/ui/conversation/v2/messages/delegate/ConversationMessagesDelegate.kt +++ b/src/com/android/messaging/ui/conversation/v2/messages/delegate/ConversationMessagesDelegate.kt @@ -1,15 +1,15 @@ package com.android.messaging.ui.conversation.v2.messages.delegate +import com.android.messaging.data.conversation.model.attachment.ConversationVCardAttachmentMetadata +import com.android.messaging.data.conversation.repository.ConversationVCardMetadataRepository import com.android.messaging.data.conversation.repository.ConversationsRepository import com.android.messaging.di.core.DefaultDispatcher +import com.android.messaging.ui.conversation.v2.attachment.mapper.ConversationVCardAttachmentUiModelMapper import com.android.messaging.ui.conversation.v2.common.ConversationScreenDelegate import com.android.messaging.ui.conversation.v2.messages.mapper.ConversationMessageUiModelMapper -import com.android.messaging.ui.conversation.v2.messages.mapper.ConversationVCardAttachmentUiModelMapper -import com.android.messaging.ui.conversation.v2.messages.model.attachment.ConversationVCardAttachmentMetadata import com.android.messaging.ui.conversation.v2.messages.model.message.ConversationMessagePartUiModel import com.android.messaging.ui.conversation.v2.messages.model.message.ConversationMessageUiModel import com.android.messaging.ui.conversation.v2.messages.model.message.ConversationMessagesUiState -import com.android.messaging.ui.conversation.v2.messages.repository.ConversationVCardMetadataRepository import javax.inject.Inject import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList diff --git a/src/com/android/messaging/ui/conversation/v2/messages/mapper/ConversationMessageUiModelMapper.kt b/src/com/android/messaging/ui/conversation/v2/messages/mapper/ConversationMessageUiModelMapper.kt index 4419823e..27678efa 100644 --- a/src/com/android/messaging/ui/conversation/v2/messages/mapper/ConversationMessageUiModelMapper.kt +++ b/src/com/android/messaging/ui/conversation/v2/messages/mapper/ConversationMessageUiModelMapper.kt @@ -3,6 +3,7 @@ package com.android.messaging.ui.conversation.v2.messages.mapper import com.android.messaging.datamodel.data.ConversationMessageData import com.android.messaging.datamodel.data.MessageData import com.android.messaging.datamodel.data.MessagePartData +import com.android.messaging.ui.conversation.v2.attachment.mapper.ConversationVCardAttachmentUiModelMapper import com.android.messaging.ui.conversation.v2.messages.model.message.ConversationMessagePartUiModel import com.android.messaging.ui.conversation.v2.messages.model.message.ConversationMessageUiModel import com.android.messaging.ui.conversation.v2.messages.model.message.ConversationMessageUiModel.Status diff --git a/src/com/android/messaging/ui/conversation/v2/messages/model/attachment/ConversationInlineAttachment.kt b/src/com/android/messaging/ui/conversation/v2/messages/model/attachment/ConversationInlineAttachment.kt index d324e585..ca809598 100644 --- a/src/com/android/messaging/ui/conversation/v2/messages/model/attachment/ConversationInlineAttachment.kt +++ b/src/com/android/messaging/ui/conversation/v2/messages/model/attachment/ConversationInlineAttachment.kt @@ -1,6 +1,7 @@ package com.android.messaging.ui.conversation.v2.messages.model.attachment import androidx.compose.runtime.Immutable +import com.android.messaging.data.conversation.model.attachment.ConversationVCardAttachmentType @Immutable internal sealed interface ConversationInlineAttachment { diff --git a/src/com/android/messaging/ui/conversation/v2/messages/model/message/ConversationMessagePartUiModel.kt b/src/com/android/messaging/ui/conversation/v2/messages/model/message/ConversationMessagePartUiModel.kt index b025ed3f..44a44fab 100644 --- a/src/com/android/messaging/ui/conversation/v2/messages/model/message/ConversationMessagePartUiModel.kt +++ b/src/com/android/messaging/ui/conversation/v2/messages/model/message/ConversationMessagePartUiModel.kt @@ -2,7 +2,7 @@ package com.android.messaging.ui.conversation.v2.messages.model.message import android.net.Uri import androidx.compose.runtime.Immutable -import com.android.messaging.ui.conversation.v2.messages.model.attachment.ConversationVCardAttachmentUiModel +import com.android.messaging.ui.conversation.v2.attachment.model.ConversationVCardAttachmentUiModel @Immutable internal sealed interface ConversationMessagePartUiModel { diff --git a/src/com/android/messaging/ui/conversation/v2/messages/ui/attachment/ConversationAttachmentSectionsBuilder.kt b/src/com/android/messaging/ui/conversation/v2/messages/ui/attachment/ConversationAttachmentSectionsBuilder.kt index 9a2f7d23..7ee1ae36 100644 --- a/src/com/android/messaging/ui/conversation/v2/messages/ui/attachment/ConversationAttachmentSectionsBuilder.kt +++ b/src/com/android/messaging/ui/conversation/v2/messages/ui/attachment/ConversationAttachmentSectionsBuilder.kt @@ -1,12 +1,12 @@ package com.android.messaging.ui.conversation.v2.messages.ui.attachment import com.android.messaging.R +import com.android.messaging.ui.conversation.v2.attachment.model.ConversationVCardAttachmentUiModel import com.android.messaging.ui.conversation.v2.messages.model.attachment.ConversationAttachmentItem import com.android.messaging.ui.conversation.v2.messages.model.attachment.ConversationAttachmentOpenAction import com.android.messaging.ui.conversation.v2.messages.model.attachment.ConversationAttachmentSections import com.android.messaging.ui.conversation.v2.messages.model.attachment.ConversationInlineAttachment import com.android.messaging.ui.conversation.v2.messages.model.attachment.ConversationMessageAttachment -import com.android.messaging.ui.conversation.v2.messages.model.attachment.ConversationVCardAttachmentUiModel import com.android.messaging.ui.conversation.v2.messages.model.message.ConversationMessagePartUiModel import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList diff --git a/src/com/android/messaging/ui/conversation/v2/messages/ui/attachment/ConversationVCardInlineAttachmentRow.kt b/src/com/android/messaging/ui/conversation/v2/messages/ui/attachment/ConversationVCardInlineAttachmentRow.kt index f1e20fea..323998aa 100644 --- a/src/com/android/messaging/ui/conversation/v2/messages/ui/attachment/ConversationVCardInlineAttachmentRow.kt +++ b/src/com/android/messaging/ui/conversation/v2/messages/ui/attachment/ConversationVCardInlineAttachmentRow.kt @@ -1,28 +1,16 @@ package com.android.messaging.ui.conversation.v2.messages.ui.attachment 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 import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.Person -import androidx.compose.material.icons.rounded.Place -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.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import com.android.messaging.ui.conversation.v2.attachment.ui.ConversationVCardAttachmentCardContent import com.android.messaging.ui.conversation.v2.messages.model.attachment.ConversationInlineAttachment -import com.android.messaging.ui.conversation.v2.messages.model.attachment.ConversationVCardAttachmentType @Composable internal fun ConversationVCardInlineAttachmentRow( @@ -88,82 +76,3 @@ internal fun ConversationVCardInlineAttachmentRowContent( ) } } - -@Composable -internal fun ConversationVCardAttachmentCardContent( - modifier: Modifier = Modifier, - type: ConversationVCardAttachmentType, - titleText: String?, - titleTextResId: Int?, - subtitleText: String?, - subtitleTextResId: Int?, -) { - val title = resolveTitleText( - titleText = titleText, - titleTextResId = titleTextResId, - ) - - val subtitle = resolveSubtitleText( - subtitleText = subtitleText, - subtitleTextResId = subtitleTextResId, - ) - - Row( - modifier = modifier, - horizontalArrangement = Arrangement.spacedBy(space = 12.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Box( - modifier = Modifier.size(size = 28.dp), - contentAlignment = Alignment.Center, - ) { - Icon( - imageVector = when (type) { - ConversationVCardAttachmentType.CONTACT -> Icons.Rounded.Person - ConversationVCardAttachmentType.LOCATION -> Icons.Rounded.Place - }, - contentDescription = null, - ) - } - - Column( - verticalArrangement = Arrangement.spacedBy(space = 2.dp), - ) { - Text( - text = title, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface, - ) - - subtitle?.let { subtitleText -> - Text( - text = subtitleText, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - } -} - -@Composable -private fun resolveTitleText( - titleText: String?, - titleTextResId: Int?, -): String { - return titleText - ?: titleTextResId?.let { titleResId -> - stringResource(titleResId) - } - .orEmpty() -} - -@Composable -private fun resolveSubtitleText( - subtitleText: String?, - subtitleTextResId: Int?, -): String? { - return subtitleText ?: subtitleTextResId?.let { subtitleResId -> - stringResource(subtitleResId) - } -} diff --git a/src/com/android/messaging/ui/conversation/v2/messages/ui/attachment/ConversationVisualAttachments.kt b/src/com/android/messaging/ui/conversation/v2/messages/ui/attachment/ConversationVisualAttachments.kt index 3244bf0a..b7409251 100644 --- a/src/com/android/messaging/ui/conversation/v2/messages/ui/attachment/ConversationVisualAttachments.kt +++ b/src/com/android/messaging/ui/conversation/v2/messages/ui/attachment/ConversationVisualAttachments.kt @@ -30,7 +30,7 @@ import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp -import com.android.messaging.ui.conversation.v2.mediapicker.component.ConversationMediaThumbnail +import com.android.messaging.ui.conversation.v2.attachment.ui.ConversationMediaThumbnail import com.android.messaging.ui.conversation.v2.messages.model.attachment.ConversationMessageAttachment import com.android.messaging.ui.conversation.v2.messages.model.message.ConversationMessagePartUiModel import com.android.messaging.util.ContentType diff --git a/src/com/android/messaging/ui/conversation/v2/screen/ConversationViewModel.kt b/src/com/android/messaging/ui/conversation/v2/screen/ConversationViewModel.kt index 7f61f44a..0b0841b9 100644 --- a/src/com/android/messaging/ui/conversation/v2/screen/ConversationViewModel.kt +++ b/src/com/android/messaging/ui/conversation/v2/screen/ConversationViewModel.kt @@ -7,6 +7,7 @@ import androidx.lifecycle.viewModelScope import com.android.messaging.R import com.android.messaging.data.conversation.model.draft.ConversationDraft import com.android.messaging.data.conversation.repository.ConversationSubscriptionsRepository +import com.android.messaging.data.media.model.ConversationCapturedMedia import com.android.messaging.datamodel.MessagingContentProvider import com.android.messaging.di.core.DefaultDispatcher import com.android.messaging.domain.conversation.usecase.action.CreateDefaultSmsRoleRequest @@ -21,8 +22,7 @@ import com.android.messaging.ui.conversation.v2.composer.model.ComposerAttachmen import com.android.messaging.ui.conversation.v2.composer.model.ConversationComposerUiState import com.android.messaging.ui.conversation.v2.entry.model.ConversationEntryStartupAttachment import com.android.messaging.ui.conversation.v2.focus.delegate.ConversationFocusDelegate -import com.android.messaging.ui.conversation.v2.mediapicker.ConversationMediaPickerDelegate -import com.android.messaging.ui.conversation.v2.mediapicker.model.ConversationCapturedMedia +import com.android.messaging.ui.conversation.v2.mediapicker.delegate.ConversationMediaPickerDelegate import com.android.messaging.ui.conversation.v2.messages.delegate.ConversationMessageSelectionDelegate import com.android.messaging.ui.conversation.v2.messages.delegate.ConversationMessagesDelegate import com.android.messaging.ui.conversation.v2.messages.model.message.ConversationMessagesUiState From 3b0384d0134905d25be13865d85f1e4e9742589e Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Fri, 1 May 2026 10:49:03 +0300 Subject: [PATCH 91/99] Update dependencies --- gradle/libs.versions.toml | 16 +- gradle/verification-metadata.xml | 4266 ++++++++++------------ gradle/wrapper/gradle-wrapper.properties | 4 +- 3 files changed, 1868 insertions(+), 2418 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 68ed526f..c86368db 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,8 +1,8 @@ [versions] -agp = "9.1.0" -detekt = "2.0.0-alpha.2" +agp = "9.2.0" +detekt = "2.0.0-alpha.3" hilt = "2.59.2" -kotlin = "2.3.20" +kotlin = "2.3.21" kotlinx-serialization = "1.11.0" ksp = "2.3.6" ktlint = "1.8.0" @@ -13,15 +13,15 @@ appcompat = "1.7.1" androidx-hilt = "1.3.0" camerax = "1.6.0" coil = "3.4.0" -compose-bom = "2026.03.01" +compose-bom = "2026.04.01" coroutines = "1.10.2" -glide = "5.0.5" -guava = "33.5.0-android" +glide = "5.0.7" +guava = "33.6.0-android" jsr305 = "3.0.2" kotlinx-collections-immutable = "0.4.0" -libphonenumber = "9.0.26" +libphonenumber = "9.0.29" lifecycle = "2.10.0" -navigation3 = "1.1.0" +navigation3 = "1.1.1" paging = "3.4.2" palette = "1.0.0" photo-picker = "1.0.0-alpha01" diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml index 2163e88c..3b47154f 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -86,6 +86,11 @@ + + + + + @@ -100,14 +105,6 @@ - - - - - - - - @@ -138,6 +135,9 @@ + + + @@ -254,6 +254,120 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -282,9 +396,6 @@ - - - @@ -313,11 +424,6 @@ - - - - - @@ -337,104 +443,90 @@ - - - + + + - - - + + + - - - + + + - + - - + + - - + + - - - + + + - - - + + + - + - + - - + + - - - + + + - - - + + + - + - - + + - - - - - - - + + - - - - - - - - - - - - + + + - - - + + + - - - + + + - - + + - - + + - - + + @@ -488,20 +580,20 @@ - - - + + + - - - + + + - + - - + + @@ -523,285 +615,303 @@ - - - + + + - - - + + + + + + + + + + + + + - + - - + + - - + + - - - + + + - - - + + + - + - + - + - - - + + + - - - + + + - + - - + + - - + + - - - + + + - - - + + + - + - + - - + + + + + + + - - - + + + - - - + + + - + - - + + - - + + - - - + + + - - - + + + - + - + - - - + + + - - - + + + - + - + - - + + - - - + + + - - - + + + - + - - + + - - + + - - - + + + - - - + + + - + - - + + - - - + + + - - + + - + - - - + + + - - - + + + - + - + - - + + - - - + + + - - - + + + - + - - + + - - + + - - - + + + - - - + + + - + - - + + - - - + + + - - - + + + - + - - + + + + + - - - + + + - - - + + + - + - + - - + + - - - + + + - - - + + + - + - - + + @@ -823,6 +933,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -848,6 +991,20 @@ + + + + + + + + + + + + + + @@ -936,25 +1093,25 @@ - - - + + + - - + + - + - - - + + + - - + + - + @@ -1017,9 +1174,6 @@ - - - @@ -1096,6 +1250,9 @@ + + + @@ -1119,6 +1276,28 @@ + + + + + + + + + + + + + + + + + + + + + + @@ -1141,6 +1320,11 @@ + + + + + @@ -1155,9 +1339,6 @@ - - - @@ -1174,9 +1355,6 @@ - - - @@ -1200,6 +1378,11 @@ + + + + + @@ -1218,6 +1401,14 @@ + + + + + + + + @@ -1230,6 +1421,11 @@ + + + + + @@ -1239,9 +1435,6 @@ - - - @@ -1296,9 +1489,6 @@ - - - @@ -1320,9 +1510,6 @@ - - - @@ -1339,9 +1526,6 @@ - - - @@ -1378,6 +1562,11 @@ + + + + + @@ -1406,9 +1595,6 @@ - - - @@ -1425,9 +1611,6 @@ - - - @@ -1470,6 +1653,22 @@ + + + + + + + + + + + + + + + + @@ -1484,9 +1683,6 @@ - - - @@ -1535,6 +1731,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -1546,23 +1775,66 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - + + + + + + + + - + - + - - + + @@ -1570,13 +1842,15 @@ + + + + + - - - @@ -1584,6 +1858,83 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -1595,6 +1946,17 @@ + + + + + + + + + + + @@ -1680,6 +2042,11 @@ + + + + + @@ -1699,11 +2066,6 @@ - - - - - @@ -1714,17 +2076,6 @@ - - - - - - - - - - - @@ -1752,14 +2103,6 @@ - - - - - - - - @@ -1786,9 +2129,6 @@ - - - @@ -1801,53 +2141,15 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + - - + + - - + + @@ -1861,46 +2163,26 @@ - - - - - - - - - - - - - - - - - - - - + + + - - + + - - + + - - - - - - + + + - - + + - - + + @@ -1914,32 +2196,26 @@ - - - - - - + + + - - + + - - + + - - - - - - + + + - - + + - - + + @@ -1953,15 +2229,50 @@ - - - + + + - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - + + + + + @@ -2119,245 +2430,245 @@ - - + + - - + + - + - - - + + + - - + + - - + + - - - + + + - - - + + + - - + + - + - - - + + + - - + + - + - - - + + + - - + + - - + + - - - + + + - - + + - + - - - + + + - - + + - - - + + + - - + + - + - - - + + + - - + + - - + + - - - + + + - - + + - - + + - - - + + + - - + + - + - - - + + + - - + + - - + + - - - + + + - - + + - - + + - - - + + + - - + + - - + + - - - + + + - - + + - - + + - - + + - + - - - + + + - - + + - - + + - - + + - - + + - + - - - + + + - - + + - + - - - + + + - - + + - - + + - - - + + + - - + + - - + + - - - + + + - - + + - - + + @@ -2368,59 +2679,59 @@ - - - + + + - - + + - - + + - - - + + + - - + + - - + + - - - + + + - - + + - - + + - - - + + + - - + + - - + + - - - + + + - - + + - - + + @@ -2445,324 +2756,267 @@ - - - + + + - - + + - - + + - - - - - - + + + - - + + - - - + + + - - + + - - - + + + - - + + - - + + - - + + - - - + + + - - + + - + - - - + + + - - + + - - - + + + - - + + - - - + + + - - + + - - - + + + - - + + - - - + + + - - + + - - + + - - - + + + - - + + - - + + - - + + - - - - - + + - - - - - - + + + - - + + - - - + + + - - - - - + + - - - - - - + + + - - + + - - + + - - - - - + + - - - - - - + + + - - + + - - - + + + - - - - - + + - - - - - - + + + - - + + - - + + - - - - - + + - - - - - - + + + - - + + - - + + - - - - - + + - - - - - - + + + - - + + - - + + - - - - - + + - - - - - - + + + - - + + - - - + + + - - - - - + + - - - - - - + + + - - + + - - - + + + - - - - - + + - - - + + + - - + + - - + + - - + + - - - - - + + @@ -2778,57 +3032,57 @@ - - - + + + - - + + - - + + - - + + - - + + - + - + - - + + - - + + - - + + - + - - - + + + - - + + - - + + - - + + @@ -2866,6 +3120,9 @@ + + + @@ -2885,15 +3142,17 @@ - - - + + + + + @@ -2901,9 +3160,6 @@ - - - @@ -2917,9 +3173,6 @@ - - - @@ -2946,6 +3199,20 @@ + + + + + + + + + + + + + + @@ -2956,6 +3223,11 @@ + + + + + @@ -2977,6 +3249,12 @@ + + + + + + @@ -2985,12 +3263,12 @@ - - - + + + @@ -3028,9 +3306,6 @@ - - - @@ -3061,8 +3336,10 @@ - - + + + + @@ -3254,6 +3531,12 @@ + + + + + + @@ -3270,12 +3553,12 @@ - - - + + + @@ -3291,18 +3574,18 @@ - - - + + + - - + + - - + + - - + + @@ -3335,6 +3618,11 @@ + + + + + @@ -3380,12 +3668,12 @@ - - - + + + @@ -3408,6 +3696,9 @@ + + + @@ -3428,12 +3719,12 @@ - - - + + + - - + + @@ -3461,9 +3752,9 @@ - - - + + + @@ -3495,6 +3786,9 @@ + + + @@ -3503,12 +3797,12 @@ - - - + + + @@ -3531,12 +3825,12 @@ - - - + + + @@ -3570,12 +3864,12 @@ - - - + + + @@ -3589,9 +3883,6 @@ - - - @@ -3600,6 +3891,9 @@ + + + @@ -3619,9 +3913,6 @@ - - - @@ -3630,9 +3921,6 @@ - - - @@ -3661,9 +3949,6 @@ - - - @@ -3672,9 +3957,6 @@ - - - @@ -3683,9 +3965,6 @@ - - - @@ -3694,9 +3973,6 @@ - - - @@ -3705,9 +3981,6 @@ - - - @@ -3716,9 +3989,6 @@ - - - @@ -3744,23 +4014,23 @@ - - - + + + - - + + - - + + - - + + - - - + + + @@ -3936,20 +4206,6 @@ - - - - - - - - - - - - - - @@ -3969,11 +4225,30 @@ + + + + + + + + + + + + + + + + + + + @@ -4002,12 +4277,12 @@ - - - + + + @@ -4036,12 +4311,12 @@ - - - + + + @@ -4077,219 +4352,219 @@ - - - + + + - - - + + + - - + + - - - + + + - - + + - - - + + + - - + + - - - + + + - - + + - - + + - - - + + + - - + + - - - + + + - - + + - - - + + + - - + + - - - + + + - - + + - - - + + + - - + + - - - + + + - - + + - - - + + + - - + + - - - + + + - - + + - - + + - - + + - - + + - - + + - - - + + + - - + + - - - + + + - - + + - - - + + + - - + + - - - + + + - - + + - - - + + + - - + + - - - + + + - - + + - - - + + + - - + + - - - + + + - - + + - - - + + + - - + + - - - + + + - - + + - - + + - - + + - - - + + + @@ -4297,9 +4572,9 @@ - - - + + + @@ -4313,15 +4588,12 @@ - - - - - - + + + - - + + @@ -4388,6 +4660,38 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -4561,9 +4865,6 @@ - - - @@ -4709,6 +5010,9 @@ + + + @@ -4728,6 +5032,9 @@ + + + @@ -4747,6 +5054,9 @@ + + + @@ -4766,6 +5076,9 @@ + + + @@ -4785,6 +5098,9 @@ + + + @@ -4804,6 +5120,9 @@ + + + @@ -4823,6 +5142,9 @@ + + + @@ -4842,6 +5164,9 @@ + + + @@ -4871,6 +5196,9 @@ + + + @@ -4890,6 +5218,9 @@ + + + @@ -4909,6 +5240,9 @@ + + + @@ -4917,9 +5251,6 @@ - - - @@ -4928,9 +5259,6 @@ - - - @@ -4939,6 +5267,9 @@ + + + @@ -4958,12 +5289,12 @@ - - - + + + @@ -4986,12 +5317,12 @@ - - - + + + @@ -5085,12 +5416,12 @@ - - - + + + @@ -5099,14 +5430,14 @@ - - - - - + + + + + @@ -5132,12 +5463,12 @@ - - - + + + @@ -5268,12 +5599,12 @@ - - - + + + @@ -5282,12 +5613,12 @@ - - - + + + @@ -5307,12 +5638,12 @@ - - - + + + @@ -5321,12 +5652,12 @@ - - - + + + @@ -5349,12 +5680,12 @@ - - - + + + @@ -5368,12 +5699,6 @@ - - - - - - @@ -5382,6 +5707,9 @@ + + + @@ -5390,6 +5718,12 @@ + + + + + + @@ -5398,12 +5732,12 @@ - - - + + + @@ -5423,6 +5757,9 @@ + + + @@ -5509,12 +5846,12 @@ - - - + + + @@ -5523,12 +5860,12 @@ - - - + + + @@ -5544,20 +5881,6 @@ - - - - - - - - - - - - - - @@ -5584,9 +5907,6 @@ - - - @@ -5595,9 +5915,6 @@ - - - @@ -5611,9 +5928,6 @@ - - - @@ -5622,9 +5936,6 @@ - - - @@ -5677,49 +5988,31 @@ - - - - - - - - - - - - - - - - - - @@ -5760,9 +6053,6 @@ - - - @@ -5812,119 +6102,115 @@ - - - + + + - - + + - + - - - + + + - - + + - - + + - - + + - - - + + + - - + + - - - + + + - - + + - + - + + + + + + - - - + + + - - + + - + - - - + + + - - + + - + - - - + + + - - - - - + + - - - - - - + + + - - + + - - - + + + - - - - - + + - - - + + + - - + + @@ -5935,44 +6221,41 @@ - - - - - - + + + - - + + - - - + + + - - + + - + - - - + + + - - + + - - - + + + - - + + - + @@ -5984,94 +6267,91 @@ - - - + + + - - - - - + + - - - + + + - - + + - - + + - - + + - - - + + + - - + + - + - - - + + + - - + + - - + + - - + + - - + + - - + + - + - - - + + + - - + + - + - - - + + + - - + + - - - + + + - - + + - + @@ -6083,17 +6363,39 @@ - - - + + + - - + + - + + + + + + + + + + + + + + + + + + + + + + + @@ -6117,9 +6419,6 @@ - - - @@ -6135,18 +6434,23 @@ - - - + + + - - + + - - + + + + + + + - - + + @@ -6157,23 +6461,34 @@ - - - + + + - - + + - - - + + + + + + + + + - - + + + + + + + - - + + @@ -6207,9 +6522,6 @@ - - - @@ -6255,6 +6567,17 @@ + + + + + + + + + + + @@ -6281,16 +6604,16 @@ - - - - - + + + + + @@ -6312,7 +6635,18 @@ - + + + + + + + + + + + + @@ -6323,25 +6657,19 @@ - - - - - - - - - - - - + - - + + + + + + + @@ -6365,7 +6693,18 @@ - + + + + + + + + + + + + @@ -6376,82 +6715,88 @@ - - - - - - - - - - - - + - - + + + + + + + - - + + - - + + - + - - - + + + - - + + - + - - - + + + - - + + - + - - - + + + - - + + - + - - - + + + + + + + + + + + + + - - + + + + @@ -6461,8 +6806,32 @@ - - + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -6483,6 +6852,12 @@ + + + + + + @@ -6504,6 +6879,11 @@ + + + + + @@ -6513,9 +6893,6 @@ - - - @@ -6531,9 +6908,6 @@ - - - @@ -6553,6 +6927,9 @@ + + + @@ -6576,6 +6953,14 @@ + + + + + + + + @@ -6611,9 +6996,14 @@ - - - + + + + + + + + @@ -6621,9 +7011,15 @@ - - - + + + + + + + + + @@ -6637,17 +7033,6 @@ - - - - - - - - - - - @@ -6725,12 +7110,12 @@ - - - + + + @@ -7067,944 +7452,9 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 8e61ef12..2b7a686b 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,7 +1,7 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionSha256Sum=2ab2958f2a1e51120c326cad6f385153bb11ee93b3c216c5fccebfdfbb7ec6cb -distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip +distributionSha256Sum=553c78f50dafcd54d65b9a444649057857469edf836431389695608536d6b746 +distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.0-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME From da254fc45bf9a73bac7a196c19ef6468db995118 Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Fri, 1 May 2026 12:04:56 +0300 Subject: [PATCH 92/99] Delete old converation screen code and make the new one default --- AndroidManifest.xml | 14 - res/layout/attachment_preview.xml | 44 - res/layout/compose_message_view.xml | 187 -- res/layout/conversation_activity.xml | 33 - res/layout/conversation_fragment.xml | 68 - res/layout/conversation_message_view.xml | 172 -- res/layout/sim_selector_item_view.xml | 65 - res/layout/sim_selector_view.xml | 32 - res/menu/conversation_menu.xml | 56 - ...t => ConversationAttachmentsRepository.kt} | 6 +- .../conversation/ConversationBindsModule.kt | 32 +- .../ConversationViewModelBindsModule.kt | 36 +- .../messaging/ui/AttachmentPreview.java | 343 ---- .../messaging/ui/AttachmentSaveTask.java | 150 ++ .../android/messaging/ui/UIIntentsImpl.java | 3 +- .../ui/conversation/ComposeMessageView.java | 1016 ---------- .../ui/conversation/ConversationActivity.java | 381 ---- .../{v2 => }/ConversationActivity.kt | 19 +- .../ConversationActivityUiState.java | 306 --- .../ConversationFastScroller.java | 479 ----- .../ui/conversation/ConversationFragment.java | 1661 ----------------- .../ui/conversation/ConversationInput.java | 103 - .../ConversationInputManager.java | 551 ------ .../ConversationMessageAdapter.java | 117 -- .../ConversationMessageBubbleView.java | 132 -- .../conversation/ConversationMessageView.java | 1195 ------------ .../conversation/ConversationSimSelector.java | 122 -- .../ConversationSubscriptionLabelResolver.kt | 2 +- .../{v2 => }/ConversationTestTags.kt | 2 +- .../EnterSelfPhoneNumberDialog.java | 93 - .../conversation/MessageBubbleBackground.java | 47 - .../ui/conversation/SimIconView.java | 54 - .../ui/conversation/SimSelectorItemView.java | 90 - .../ui/conversation/SimSelectorView.java | 169 -- .../addparticipants/AddParticipantsScreen.kt | 20 +- .../AddParticipantsViewModel.kt | 8 +- .../model/AddParticipantsEffect.kt | 2 +- .../model/AddParticipantsUiState.kt | 4 +- ...onversationVCardAttachmentUiModelMapper.kt | 4 +- .../ConversationVCardAttachmentUiModel.kt | 2 +- .../ui/ConversationMediaThumbnail.kt | 2 +- .../ConversationMediaThumbnailBitmapLoader.kt | 2 +- .../ConversationVCardAttachmentCardContent.kt | 2 +- .../ConversationAudioDurationFormatter.kt | 2 +- .../ConversationAudioRecordingDelegate.kt | 16 +- .../ConversationAudioRecordingUiState.kt | 2 +- .../common/ConversationScreenDelegate.kt | 2 +- ...ConversationComposerAttachmentsDelegate.kt | 10 +- .../delegate/ConversationDraftDelegate.kt | 8 +- .../delegate/ConversationDraftEditorState.kt | 4 +- ...ersationComposerAttachmentUiModelMapper.kt | 6 +- .../ConversationComposerUiStateMapper.kt | 14 +- .../model/ComposerAttachmentUiModel.kt | 4 +- .../model/ConversationComposerUiState.kt | 4 +- .../composer/model/ConversationDraftState.kt | 2 +- ...onversationSendActionButtonGestureState.kt | 2 +- .../model/ConversationSendActionButtonMode.kt | 2 +- .../model/ConversationSimSelectorUiState.kt | 2 +- .../ui/ConversationAttachmentPreview.kt | 18 +- .../ui/ConversationAudioRecordingBar.kt | 10 +- .../composer/ui/ConversationComposeBar.kt | 18 +- .../ui/ConversationComposeMessageField.kt | 14 +- .../ui/ConversationComposerSection.kt | 6 +- .../ui/ConversationSendActionButton.kt | 6 +- .../ui/ConversationSendActionButtonGesture.kt | 6 +- .../ui/ConversationSimSelectorSheet.kt | 10 +- .../entry/ConversationEntryViewModel.kt | 10 +- .../{v2 => }/entry/NewChatScreen.kt | 24 +- .../entry/model/ConversationEntryEffect.kt | 4 +- .../model/ConversationEntryLaunchRequest.kt | 2 +- .../entry/model/ConversationEntryUiState.kt | 2 +- .../delegate/ConversationFocusDelegate.kt | 2 +- .../mediapicker/ConversationMediaPicker.kt | 10 +- .../ConversationMediaPickerCaptureRoute.kt | 14 +- .../ConversationMediaPickerCaptureScene.kt | 6 +- .../ConversationMediaPickerOverlay.kt | 6 +- .../ConversationMediaPickerPermission.kt | 4 +- .../ConversationMediaPickerScaffold.kt | 8 +- .../ConversationMediaPickerSheetScaffold.kt | 2 +- .../ConversationMediaPickerState.kt | 2 +- .../camera/ConversationCameraController.kt | 2 +- .../camera/ConversationCameraEffects.kt | 2 +- .../camera/ConversationMediaPickerActions.kt | 2 +- .../camera/ConversationPhotoFlashMode.kt | 2 +- .../{v2 => }/mediapicker/camera/Exceptions.kt | 2 +- .../ConversationMediaPickerShared.kt | 2 +- .../ConversationMediaCaptureControls.kt | 10 +- .../ConversationMediaCaptureShutterButton.kt | 10 +- .../capture/ConversationMediaPickerCapture.kt | 8 +- .../review/ConversationMediaPickerReview.kt | 10 +- .../ConversationMediaReviewBackground.kt | 6 +- .../ConversationMediaReviewBitmapCache.kt | 2 +- .../review/ConversationMediaReviewPageCard.kt | 8 +- .../ConversationMediaReviewPagerState.kt | 4 +- .../ConversationMediaPickerDelegate.kt | 18 +- .../ConversationDraftAttachmentMapper.kt | 2 +- .../ConversationMediaPickerPermissionState.kt | 2 +- .../ConversationMessageSelectionDelegate.kt | 24 +- .../delegate/ConversationMessagesDelegate.kt | 14 +- .../ConversationMessageUiModelMapper.kt | 10 +- .../ConversationAttachmentOpenAction.kt | 2 +- .../ConversationAttachmentSections.kt | 2 +- .../ConversationInlineAttachment.kt | 2 +- .../ConversationMessageAttachment.kt | 4 +- .../message/ConversationMessageContent.kt | 6 +- .../message/ConversationMessagePartUiModel.kt | 4 +- .../message/ConversationMessageUiModel.kt | 2 +- .../message/ConversationMessagesUiState.kt | 2 +- .../model/text/ConversationTextLink.kt | 2 +- .../messages/ui/ConversationMessages.kt | 14 +- .../ConversationAttachmentActionDispatcher.kt | 6 +- .../ConversationAttachmentSectionsBuilder.kt | 16 +- .../ConversationGenericInlineAttachmentRow.kt | 4 +- .../ConversationInlineAttachmentRow.kt | 4 +- ...ationInlineAudioAttachmentPlaybackState.kt | 4 +- .../ConversationInlineAudioAttachmentRow.kt | 8 +- .../ConversationMessageAttachments.kt | 6 +- .../ConversationVCardInlineAttachmentRow.kt | 6 +- .../ConversationVisualAttachments.kt | 8 +- .../ui/message/ConversationMessage.kt | 8 +- .../ui/message/ConversationMessageBubble.kt | 10 +- .../ConversationMessageContentBuilder.kt | 12 +- .../ConversationMessageDateFormatting.kt | 4 +- .../ui/message/ConversationMessageMetadata.kt | 6 +- .../ui/text/ConversationMessageText.kt | 4 +- .../ConversationMessageTextLinkExtractor.kt | 4 +- .../delegate/ConversationMetadataDelegate.kt | 10 +- .../ConversationMetadataUiStateMapper.kt | 4 +- .../model/ConversationMetadataUiState.kt | 2 +- .../metadata/ui/ConversationTopAppBar.kt | 24 +- .../navigation/ConversationNavGraph.kt | 22 +- .../{v2 => }/navigation/ConversationNavKey.kt | 2 +- .../ConversationNavigationReducer.kt | 2 +- .../recipientpicker/RecipientPickerScreen.kt | 4 +- .../RecipientPickerViewModel.kt | 6 +- .../RecipientSelectionContactAvatar.kt | 4 +- .../RecipientSelectionContactRow.kt | 4 +- .../RecipientSelectionContactsContent.kt | 6 +- .../RecipientSelectionContent.kt | 4 +- .../RecipientSelectionContentUiState.kt | 6 +- .../RecipientSelectionPrimaryActionButton.kt | 2 +- .../delegate/RecipientPickerDelegate.kt | 6 +- .../model/RecipientPickerListItem.kt | 2 +- .../model/RecipientPickerUiState.kt | 2 +- .../screen/ConversationAutoScrollPolicy.kt | 2 +- .../{v2 => }/screen/ConversationScreen.kt | 26 +- .../screen/ConversationScreenEffects.kt | 4 +- .../screen/ConversationScreenRoute.kt | 18 +- .../screen/ConversationSelectionTopAppBar.kt | 6 +- .../{v2 => }/screen/ConversationViewModel.kt | 40 +- .../screen/PendingAudioRecordingStartMode.kt | 2 +- .../ConversationMediaPickerOverlayUiState.kt | 4 +- .../ConversationMessageSelectionUiState.kt | 2 +- .../screen/model/ConversationScreenEffect.kt | 2 +- .../ConversationScreenScaffoldUiState.kt | 8 +- .../ui/mediapicker/MediaPickerPanel.java | 8 +- .../photoviewer/BuglePhotoViewController.java | 4 +- 157 files changed, 621 insertions(+), 7997 deletions(-) delete mode 100644 res/layout/attachment_preview.xml delete mode 100644 res/layout/compose_message_view.xml delete mode 100644 res/layout/conversation_activity.xml delete mode 100644 res/layout/conversation_fragment.xml delete mode 100644 res/layout/conversation_message_view.xml delete mode 100644 res/layout/sim_selector_item_view.xml delete mode 100644 res/layout/sim_selector_view.xml delete mode 100644 res/menu/conversation_menu.xml rename src/com/android/messaging/data/media/repository/{ConversationAttachmentRepository.kt => ConversationAttachmentsRepository.kt} (99%) delete mode 100644 src/com/android/messaging/ui/AttachmentPreview.java create mode 100644 src/com/android/messaging/ui/AttachmentSaveTask.java delete mode 100644 src/com/android/messaging/ui/conversation/ComposeMessageView.java delete mode 100644 src/com/android/messaging/ui/conversation/ConversationActivity.java rename src/com/android/messaging/ui/conversation/{v2 => }/ConversationActivity.kt (87%) delete mode 100644 src/com/android/messaging/ui/conversation/ConversationActivityUiState.java delete mode 100644 src/com/android/messaging/ui/conversation/ConversationFastScroller.java delete mode 100644 src/com/android/messaging/ui/conversation/ConversationFragment.java delete mode 100644 src/com/android/messaging/ui/conversation/ConversationInput.java delete mode 100644 src/com/android/messaging/ui/conversation/ConversationInputManager.java delete mode 100644 src/com/android/messaging/ui/conversation/ConversationMessageAdapter.java delete mode 100644 src/com/android/messaging/ui/conversation/ConversationMessageBubbleView.java delete mode 100644 src/com/android/messaging/ui/conversation/ConversationMessageView.java delete mode 100644 src/com/android/messaging/ui/conversation/ConversationSimSelector.java rename src/com/android/messaging/ui/conversation/{v2 => }/ConversationSubscriptionLabelResolver.kt (94%) rename src/com/android/messaging/ui/conversation/{v2 => }/ConversationTestTags.kt (98%) delete mode 100644 src/com/android/messaging/ui/conversation/EnterSelfPhoneNumberDialog.java delete mode 100644 src/com/android/messaging/ui/conversation/MessageBubbleBackground.java delete mode 100644 src/com/android/messaging/ui/conversation/SimIconView.java delete mode 100644 src/com/android/messaging/ui/conversation/SimSelectorItemView.java delete mode 100644 src/com/android/messaging/ui/conversation/SimSelectorView.java rename src/com/android/messaging/ui/conversation/{v2 => }/addparticipants/AddParticipantsScreen.kt (86%) rename src/com/android/messaging/ui/conversation/{v2 => }/addparticipants/AddParticipantsViewModel.kt (96%) rename src/com/android/messaging/ui/conversation/{v2 => }/addparticipants/model/AddParticipantsEffect.kt (77%) rename src/com/android/messaging/ui/conversation/{v2 => }/addparticipants/model/AddParticipantsUiState.kt (79%) rename src/com/android/messaging/ui/conversation/{v2 => }/attachment/mapper/ConversationVCardAttachmentUiModelMapper.kt (96%) rename src/com/android/messaging/ui/conversation/{v2 => }/attachment/model/ConversationVCardAttachmentUiModel.kt (86%) rename src/com/android/messaging/ui/conversation/{v2 => }/attachment/ui/ConversationMediaThumbnail.kt (99%) rename src/com/android/messaging/ui/conversation/{v2 => }/attachment/ui/ConversationMediaThumbnailBitmapLoader.kt (99%) rename src/com/android/messaging/ui/conversation/{v2 => }/attachment/ui/ConversationVCardAttachmentCardContent.kt (97%) rename src/com/android/messaging/ui/conversation/{v2 => }/audio/ConversationAudioDurationFormatter.kt (89%) rename src/com/android/messaging/ui/conversation/{v2 => }/audio/delegate/ConversationAudioRecordingDelegate.kt (97%) rename src/com/android/messaging/ui/conversation/{v2 => }/audio/model/ConversationAudioRecordingUiState.kt (85%) rename src/com/android/messaging/ui/conversation/{v2 => }/common/ConversationScreenDelegate.kt (82%) rename src/com/android/messaging/ui/conversation/{v2 => }/composer/delegate/ConversationComposerAttachmentsDelegate.kt (93%) rename src/com/android/messaging/ui/conversation/{v2 => }/composer/delegate/ConversationDraftDelegate.kt (98%) rename src/com/android/messaging/ui/conversation/{v2 => }/composer/delegate/ConversationDraftEditorState.kt (99%) rename src/com/android/messaging/ui/conversation/{v2 => }/composer/mapper/ConversationComposerAttachmentUiModelMapper.kt (95%) rename src/com/android/messaging/ui/conversation/{v2 => }/composer/mapper/ConversationComposerUiStateMapper.kt (86%) rename src/com/android/messaging/ui/conversation/{v2 => }/composer/model/ComposerAttachmentUiModel.kt (93%) rename src/com/android/messaging/ui/conversation/{v2 => }/composer/model/ConversationComposerUiState.kt (90%) rename src/com/android/messaging/ui/conversation/{v2 => }/composer/model/ConversationDraftState.kt (89%) rename src/com/android/messaging/ui/conversation/{v2 => }/composer/model/ConversationSendActionButtonGestureState.kt (75%) rename src/com/android/messaging/ui/conversation/{v2 => }/composer/model/ConversationSendActionButtonMode.kt (69%) rename src/com/android/messaging/ui/conversation/{v2 => }/composer/model/ConversationSimSelectorUiState.kt (89%) rename src/com/android/messaging/ui/conversation/{v2 => }/composer/ui/ConversationAttachmentPreview.kt (95%) rename src/com/android/messaging/ui/conversation/{v2 => }/composer/ui/ConversationAudioRecordingBar.kt (96%) rename src/com/android/messaging/ui/conversation/{v2 => }/composer/ui/ConversationComposeBar.kt (96%) rename src/com/android/messaging/ui/conversation/{v2 => }/composer/ui/ConversationComposeMessageField.kt (94%) rename src/com/android/messaging/ui/conversation/{v2 => }/composer/ui/ConversationComposerSection.kt (92%) rename src/com/android/messaging/ui/conversation/{v2 => }/composer/ui/ConversationSendActionButton.kt (98%) rename src/com/android/messaging/ui/conversation/{v2 => }/composer/ui/ConversationSendActionButtonGesture.kt (97%) rename src/com/android/messaging/ui/conversation/{v2 => }/composer/ui/ConversationSimSelectorSheet.kt (93%) rename src/com/android/messaging/ui/conversation/{v2 => }/entry/ConversationEntryViewModel.kt (97%) rename src/com/android/messaging/ui/conversation/{v2 => }/entry/NewChatScreen.kt (92%) rename src/com/android/messaging/ui/conversation/{v2 => }/entry/model/ConversationEntryEffect.kt (75%) rename src/com/android/messaging/ui/conversation/{v2 => }/entry/model/ConversationEntryLaunchRequest.kt (87%) rename src/com/android/messaging/ui/conversation/{v2 => }/entry/model/ConversationEntryUiState.kt (94%) rename src/com/android/messaging/ui/conversation/{v2 => }/focus/delegate/ConversationFocusDelegate.kt (98%) rename src/com/android/messaging/ui/conversation/{v2 => }/mediapicker/ConversationMediaPicker.kt (97%) rename src/com/android/messaging/ui/conversation/{v2 => }/mediapicker/ConversationMediaPickerCaptureRoute.kt (81%) rename src/com/android/messaging/ui/conversation/{v2 => }/mediapicker/ConversationMediaPickerCaptureScene.kt (90%) rename src/com/android/messaging/ui/conversation/{v2 => }/mediapicker/ConversationMediaPickerOverlay.kt (94%) rename src/com/android/messaging/ui/conversation/{v2 => }/mediapicker/ConversationMediaPickerPermission.kt (91%) rename src/com/android/messaging/ui/conversation/{v2 => }/mediapicker/ConversationMediaPickerScaffold.kt (95%) rename src/com/android/messaging/ui/conversation/{v2 => }/mediapicker/ConversationMediaPickerSheetScaffold.kt (98%) rename src/com/android/messaging/ui/conversation/{v2 => }/mediapicker/ConversationMediaPickerState.kt (98%) rename src/com/android/messaging/ui/conversation/{v2 => }/mediapicker/camera/ConversationCameraController.kt (99%) rename src/com/android/messaging/ui/conversation/{v2 => }/mediapicker/camera/ConversationCameraEffects.kt (94%) rename src/com/android/messaging/ui/conversation/{v2 => }/mediapicker/camera/ConversationMediaPickerActions.kt (96%) rename src/com/android/messaging/ui/conversation/{v2 => }/mediapicker/camera/ConversationPhotoFlashMode.kt (88%) rename src/com/android/messaging/ui/conversation/{v2 => }/mediapicker/camera/Exceptions.kt (96%) rename src/com/android/messaging/ui/conversation/{v2 => }/mediapicker/component/ConversationMediaPickerShared.kt (98%) rename src/com/android/messaging/ui/conversation/{v2 => }/mediapicker/component/capture/ConversationMediaCaptureControls.kt (94%) rename src/com/android/messaging/ui/conversation/{v2 => }/mediapicker/component/capture/ConversationMediaCaptureShutterButton.kt (96%) rename src/com/android/messaging/ui/conversation/{v2 => }/mediapicker/component/capture/ConversationMediaPickerCapture.kt (93%) rename src/com/android/messaging/ui/conversation/{v2 => }/mediapicker/component/review/ConversationMediaPickerReview.kt (97%) rename src/com/android/messaging/ui/conversation/{v2 => }/mediapicker/component/review/ConversationMediaReviewBackground.kt (96%) rename src/com/android/messaging/ui/conversation/{v2 => }/mediapicker/component/review/ConversationMediaReviewBitmapCache.kt (91%) rename src/com/android/messaging/ui/conversation/{v2 => }/mediapicker/component/review/ConversationMediaReviewPageCard.kt (96%) rename src/com/android/messaging/ui/conversation/{v2 => }/mediapicker/component/review/ConversationMediaReviewPagerState.kt (97%) rename src/com/android/messaging/ui/conversation/{v2 => }/mediapicker/delegate/ConversationMediaPickerDelegate.kt (95%) rename src/com/android/messaging/ui/conversation/{v2 => }/mediapicker/mapper/ConversationDraftAttachmentMapper.kt (94%) rename src/com/android/messaging/ui/conversation/{v2 => }/mediapicker/model/ConversationMediaPickerPermissionState.kt (95%) rename src/com/android/messaging/ui/conversation/{v2 => }/messages/delegate/ConversationMessageSelectionDelegate.kt (94%) rename src/com/android/messaging/ui/conversation/{v2 => }/messages/delegate/ConversationMessagesDelegate.kt (91%) rename src/com/android/messaging/ui/conversation/{v2 => }/messages/mapper/ConversationMessageUiModelMapper.kt (94%) rename src/com/android/messaging/ui/conversation/{v2 => }/messages/model/attachment/ConversationAttachmentOpenAction.kt (83%) rename src/com/android/messaging/ui/conversation/{v2 => }/messages/model/attachment/ConversationAttachmentSections.kt (90%) rename src/com/android/messaging/ui/conversation/{v2 => }/messages/model/attachment/ConversationInlineAttachment.kt (94%) rename src/com/android/messaging/ui/conversation/{v2 => }/messages/model/attachment/ConversationMessageAttachment.kt (79%) rename src/com/android/messaging/ui/conversation/{v2 => }/messages/model/message/ConversationMessageContent.kt (57%) rename src/com/android/messaging/ui/conversation/{v2 => }/messages/model/message/ConversationMessagePartUiModel.kt (92%) rename src/com/android/messaging/ui/conversation/{v2 => }/messages/model/message/ConversationMessageUiModel.kt (96%) rename src/com/android/messaging/ui/conversation/{v2 => }/messages/model/message/ConversationMessagesUiState.kt (86%) rename src/com/android/messaging/ui/conversation/{v2 => }/messages/model/text/ConversationTextLink.kt (69%) rename src/com/android/messaging/ui/conversation/{v2 => }/messages/ui/ConversationMessages.kt (94%) rename src/com/android/messaging/ui/conversation/{v2 => }/messages/ui/attachment/ConversationAttachmentActionDispatcher.kt (83%) rename src/com/android/messaging/ui/conversation/{v2 => }/messages/ui/attachment/ConversationAttachmentSectionsBuilder.kt (88%) rename src/com/android/messaging/ui/conversation/{v2 => }/messages/ui/attachment/ConversationGenericInlineAttachmentRow.kt (95%) rename src/com/android/messaging/ui/conversation/{v2 => }/messages/ui/attachment/ConversationInlineAttachmentRow.kt (89%) rename src/com/android/messaging/ui/conversation/{v2 => }/messages/ui/attachment/ConversationInlineAudioAttachmentPlaybackState.kt (97%) rename src/com/android/messaging/ui/conversation/{v2 => }/messages/ui/attachment/ConversationInlineAudioAttachmentRow.kt (96%) rename src/com/android/messaging/ui/conversation/{v2 => }/messages/ui/attachment/ConversationMessageAttachments.kt (91%) rename src/com/android/messaging/ui/conversation/{v2 => }/messages/ui/attachment/ConversationVCardInlineAttachmentRow.kt (89%) rename src/com/android/messaging/ui/conversation/{v2 => }/messages/ui/attachment/ConversationVisualAttachments.kt (97%) rename src/com/android/messaging/ui/conversation/{v2 => }/messages/ui/message/ConversationMessage.kt (97%) rename src/com/android/messaging/ui/conversation/{v2 => }/messages/ui/message/ConversationMessageBubble.kt (97%) rename src/com/android/messaging/ui/conversation/{v2 => }/messages/ui/message/ConversationMessageContentBuilder.kt (89%) rename src/com/android/messaging/ui/conversation/{v2 => }/messages/ui/message/ConversationMessageDateFormatting.kt (91%) rename src/com/android/messaging/ui/conversation/{v2 => }/messages/ui/message/ConversationMessageMetadata.kt (84%) rename src/com/android/messaging/ui/conversation/{v2 => }/messages/ui/text/ConversationMessageText.kt (95%) rename src/com/android/messaging/ui/conversation/{v2 => }/messages/ui/text/ConversationMessageTextLinkExtractor.kt (95%) rename src/com/android/messaging/ui/conversation/{v2 => }/metadata/delegate/ConversationMetadataDelegate.kt (92%) rename src/com/android/messaging/ui/conversation/{v2 => }/metadata/mapper/ConversationMetadataUiStateMapper.kt (90%) rename src/com/android/messaging/ui/conversation/{v2 => }/metadata/model/ConversationMetadataUiState.kt (96%) rename src/com/android/messaging/ui/conversation/{v2 => }/metadata/ui/ConversationTopAppBar.kt (95%) rename src/com/android/messaging/ui/conversation/{v2 => }/navigation/ConversationNavGraph.kt (94%) rename src/com/android/messaging/ui/conversation/{v2 => }/navigation/ConversationNavKey.kt (90%) rename src/com/android/messaging/ui/conversation/{v2 => }/navigation/ConversationNavigationReducer.kt (98%) rename src/com/android/messaging/ui/conversation/{v2 => }/recipientpicker/RecipientPickerScreen.kt (91%) rename src/com/android/messaging/ui/conversation/{v2 => }/recipientpicker/RecipientPickerViewModel.kt (81%) rename src/com/android/messaging/ui/conversation/{v2 => }/recipientpicker/RecipientSelectionContactAvatar.kt (97%) rename src/com/android/messaging/ui/conversation/{v2 => }/recipientpicker/RecipientSelectionContactRow.kt (98%) rename src/com/android/messaging/ui/conversation/{v2 => }/recipientpicker/RecipientSelectionContactsContent.kt (97%) rename src/com/android/messaging/ui/conversation/{v2 => }/recipientpicker/RecipientSelectionContent.kt (96%) rename src/com/android/messaging/ui/conversation/{v2 => }/recipientpicker/RecipientSelectionContentUiState.kt (80%) rename src/com/android/messaging/ui/conversation/{v2 => }/recipientpicker/RecipientSelectionPrimaryActionButton.kt (98%) rename src/com/android/messaging/ui/conversation/{v2 => }/recipientpicker/delegate/RecipientPickerDelegate.kt (98%) rename src/com/android/messaging/ui/conversation/{v2 => }/recipientpicker/model/RecipientPickerListItem.kt (92%) rename src/com/android/messaging/ui/conversation/{v2 => }/recipientpicker/model/RecipientPickerUiState.kt (86%) rename src/com/android/messaging/ui/conversation/{v2 => }/screen/ConversationAutoScrollPolicy.kt (96%) rename src/com/android/messaging/ui/conversation/{v2 => }/screen/ConversationScreen.kt (94%) rename src/com/android/messaging/ui/conversation/{v2 => }/screen/ConversationScreenEffects.kt (98%) rename src/com/android/messaging/ui/conversation/{v2 => }/screen/ConversationScreenRoute.kt (92%) rename src/com/android/messaging/ui/conversation/{v2 => }/screen/ConversationSelectionTopAppBar.kt (97%) rename src/com/android/messaging/ui/conversation/{v2 => }/screen/ConversationViewModel.kt (93%) rename src/com/android/messaging/ui/conversation/{v2 => }/screen/PendingAudioRecordingStartMode.kt (62%) rename src/com/android/messaging/ui/conversation/{v2 => }/screen/model/ConversationMediaPickerOverlayUiState.kt (80%) rename src/com/android/messaging/ui/conversation/{v2 => }/screen/model/ConversationMessageSelectionUiState.kt (94%) rename src/com/android/messaging/ui/conversation/{v2 => }/screen/model/ConversationScreenEffect.kt (96%) rename src/com/android/messaging/ui/conversation/{v2 => }/screen/model/ConversationScreenScaffoldUiState.kt (68%) diff --git a/AndroidManifest.xml b/AndroidManifest.xml index be455c56..d0f7daa6 100644 --- a/AndroidManifest.xml +++ b/AndroidManifest.xml @@ -132,20 +132,6 @@ android:allowEmbedded="true" android:resizeableActivity="true" android:windowSoftInputMode="stateHidden|adjustResize" - android:theme="@style/BugleTheme.ConversationActivity" - android:parentActivityName="com.android.messaging.ui.conversationlist.ConversationListActivity"> - - - - - - - - - - - - - \ No newline at end of file diff --git a/res/layout/compose_message_view.xml b/res/layout/compose_message_view.xml deleted file mode 100644 index 8bb82494..00000000 --- a/res/layout/compose_message_view.xml +++ /dev/null @@ -1,187 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/res/layout/conversation_activity.xml b/res/layout/conversation_activity.xml deleted file mode 100644 index 88797044..00000000 --- a/res/layout/conversation_activity.xml +++ /dev/null @@ -1,33 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/res/layout/conversation_fragment.xml b/res/layout/conversation_fragment.xml deleted file mode 100644 index 0bf42f59..00000000 --- a/res/layout/conversation_fragment.xml +++ /dev/null @@ -1,68 +0,0 @@ - - - - - - - - - - - - - - - - - - - - diff --git a/res/layout/conversation_message_view.xml b/res/layout/conversation_message_view.xml deleted file mode 100644 index 25d3840d..00000000 --- a/res/layout/conversation_message_view.xml +++ /dev/null @@ -1,172 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/res/layout/sim_selector_item_view.xml b/res/layout/sim_selector_item_view.xml deleted file mode 100644 index a20c4a98..00000000 --- a/res/layout/sim_selector_item_view.xml +++ /dev/null @@ -1,65 +0,0 @@ - - - - - - - - - - - - - - - diff --git a/res/layout/sim_selector_view.xml b/res/layout/sim_selector_view.xml deleted file mode 100644 index 816a2cc8..00000000 --- a/res/layout/sim_selector_view.xml +++ /dev/null @@ -1,32 +0,0 @@ - - - - - - - diff --git a/res/menu/conversation_menu.xml b/res/menu/conversation_menu.xml deleted file mode 100644 index 7817a1f3..00000000 --- a/res/menu/conversation_menu.xml +++ /dev/null @@ -1,56 +0,0 @@ - - - - - - - - - - - - - - - - - diff --git a/src/com/android/messaging/data/media/repository/ConversationAttachmentRepository.kt b/src/com/android/messaging/data/media/repository/ConversationAttachmentsRepository.kt similarity index 99% rename from src/com/android/messaging/data/media/repository/ConversationAttachmentRepository.kt rename to src/com/android/messaging/data/media/repository/ConversationAttachmentsRepository.kt index e7c803b5..7b33db62 100644 --- a/src/com/android/messaging/data/media/repository/ConversationAttachmentRepository.kt +++ b/src/com/android/messaging/data/media/repository/ConversationAttachmentsRepository.kt @@ -31,7 +31,7 @@ import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOn -internal interface ConversationAttachmentRepository { +internal interface ConversationAttachmentsRepository { fun createDraftAttachmentsFromPhotoPicker( contentUris: List, ): Flow @@ -49,11 +49,11 @@ internal interface ConversationAttachmentRepository { ): Flow } -internal class ConversationAttachmentRepositoryImpl @Inject constructor( +internal class ConversationAttachmentsRepositoryImpl @Inject constructor( private val contentResolver: ContentResolver, @param:IoDispatcher private val ioDispatcher: CoroutineDispatcher, -) : ConversationAttachmentRepository { +) : ConversationAttachmentsRepository { @Suppress("TooGenericExceptionCaught") override fun createDraftAttachmentsFromPhotoPicker( diff --git a/src/com/android/messaging/di/conversation/ConversationBindsModule.kt b/src/com/android/messaging/di/conversation/ConversationBindsModule.kt index 72800218..67a474b1 100644 --- a/src/com/android/messaging/di/conversation/ConversationBindsModule.kt +++ b/src/com/android/messaging/di/conversation/ConversationBindsModule.kt @@ -20,8 +20,8 @@ import com.android.messaging.data.conversation.repository.ConversationsRepositor import com.android.messaging.data.conversation.repository.ConversationsRepositoryImpl import com.android.messaging.data.conversation.store.ConversationDraftStore import com.android.messaging.data.conversation.store.ConversationDraftStoreImpl -import com.android.messaging.data.media.repository.ConversationAttachmentRepository -import com.android.messaging.data.media.repository.ConversationAttachmentRepositoryImpl +import com.android.messaging.data.media.repository.ConversationAttachmentsRepository +import com.android.messaging.data.media.repository.ConversationAttachmentsRepositoryImpl import com.android.messaging.data.media.repository.ConversationMediaRepository import com.android.messaging.data.media.repository.ConversationMediaRepositoryImpl import com.android.messaging.domain.contacts.usecase.IsReadContactsPermissionGranted @@ -48,18 +48,18 @@ import com.android.messaging.domain.conversation.usecase.telephony.IsDeviceVoice import com.android.messaging.domain.conversation.usecase.telephony.IsDeviceVoiceCapableImpl import com.android.messaging.domain.conversation.usecase.telephony.IsEmergencyPhoneNumber import com.android.messaging.domain.conversation.usecase.telephony.IsEmergencyPhoneNumberImpl -import com.android.messaging.ui.conversation.v2.attachment.mapper.ConversationVCardAttachmentUiModelMapper -import com.android.messaging.ui.conversation.v2.attachment.mapper.ConversationVCardAttachmentUiModelMapperImpl -import com.android.messaging.ui.conversation.v2.composer.mapper.ConversationComposerAttachmentUiModelMapper -import com.android.messaging.ui.conversation.v2.composer.mapper.ConversationComposerAttachmentUiModelMapperImpl -import com.android.messaging.ui.conversation.v2.composer.mapper.ConversationComposerUiStateMapper -import com.android.messaging.ui.conversation.v2.composer.mapper.ConversationComposerUiStateMapperImpl -import com.android.messaging.ui.conversation.v2.mediapicker.mapper.ConversationDraftAttachmentMapper -import com.android.messaging.ui.conversation.v2.mediapicker.mapper.ConversationDraftAttachmentMapperImpl -import com.android.messaging.ui.conversation.v2.messages.mapper.ConversationMessageUiModelMapper -import com.android.messaging.ui.conversation.v2.messages.mapper.ConversationMessageUiModelMapperImpl -import com.android.messaging.ui.conversation.v2.metadata.mapper.ConversationMetadataUiStateMapper -import com.android.messaging.ui.conversation.v2.metadata.mapper.ConversationMetadataUiStateMapperImpl +import com.android.messaging.ui.conversation.attachment.mapper.ConversationVCardAttachmentUiModelMapper +import com.android.messaging.ui.conversation.attachment.mapper.ConversationVCardAttachmentUiModelMapperImpl +import com.android.messaging.ui.conversation.composer.mapper.ConversationComposerAttachmentUiModelMapper +import com.android.messaging.ui.conversation.composer.mapper.ConversationComposerAttachmentUiModelMapperImpl +import com.android.messaging.ui.conversation.composer.mapper.ConversationComposerUiStateMapper +import com.android.messaging.ui.conversation.composer.mapper.ConversationComposerUiStateMapperImpl +import com.android.messaging.ui.conversation.mediapicker.mapper.ConversationDraftAttachmentMapper +import com.android.messaging.ui.conversation.mediapicker.mapper.ConversationDraftAttachmentMapperImpl +import com.android.messaging.ui.conversation.messages.mapper.ConversationMessageUiModelMapper +import com.android.messaging.ui.conversation.messages.mapper.ConversationMessageUiModelMapperImpl +import com.android.messaging.ui.conversation.metadata.mapper.ConversationMetadataUiStateMapper +import com.android.messaging.ui.conversation.metadata.mapper.ConversationMetadataUiStateMapperImpl import dagger.Binds import dagger.Module import dagger.Reusable @@ -187,8 +187,8 @@ internal abstract class ConversationBindsModule { @Binds @Reusable abstract fun bindConversationAttachmentRepository( - impl: ConversationAttachmentRepositoryImpl, - ): ConversationAttachmentRepository + impl: ConversationAttachmentsRepositoryImpl, + ): ConversationAttachmentsRepository @Binds @Reusable diff --git a/src/com/android/messaging/di/conversation/ConversationViewModelBindsModule.kt b/src/com/android/messaging/di/conversation/ConversationViewModelBindsModule.kt index 73b78947..30a25d68 100644 --- a/src/com/android/messaging/di/conversation/ConversationViewModelBindsModule.kt +++ b/src/com/android/messaging/di/conversation/ConversationViewModelBindsModule.kt @@ -1,23 +1,23 @@ package com.android.messaging.di.conversation -import com.android.messaging.ui.conversation.v2.audio.delegate.ConversationAudioRecordingDelegate -import com.android.messaging.ui.conversation.v2.audio.delegate.ConversationAudioRecordingDelegateImpl -import com.android.messaging.ui.conversation.v2.composer.delegate.ConversationComposerAttachmentsDelegate -import com.android.messaging.ui.conversation.v2.composer.delegate.ConversationComposerAttachmentsDelegateImpl -import com.android.messaging.ui.conversation.v2.composer.delegate.ConversationDraftDelegate -import com.android.messaging.ui.conversation.v2.composer.delegate.ConversationDraftDelegateImpl -import com.android.messaging.ui.conversation.v2.focus.delegate.ConversationFocusDelegate -import com.android.messaging.ui.conversation.v2.focus.delegate.ConversationFocusDelegateImpl -import com.android.messaging.ui.conversation.v2.mediapicker.delegate.ConversationMediaPickerDelegate -import com.android.messaging.ui.conversation.v2.mediapicker.delegate.ConversationMediaPickerDelegateImpl -import com.android.messaging.ui.conversation.v2.messages.delegate.ConversationMessageSelectionDelegate -import com.android.messaging.ui.conversation.v2.messages.delegate.ConversationMessageSelectionDelegateImpl -import com.android.messaging.ui.conversation.v2.messages.delegate.ConversationMessagesDelegate -import com.android.messaging.ui.conversation.v2.messages.delegate.ConversationMessagesDelegateImpl -import com.android.messaging.ui.conversation.v2.metadata.delegate.ConversationMetadataDelegate -import com.android.messaging.ui.conversation.v2.metadata.delegate.ConversationMetadataDelegateImpl -import com.android.messaging.ui.conversation.v2.recipientpicker.delegate.RecipientPickerDelegate -import com.android.messaging.ui.conversation.v2.recipientpicker.delegate.RecipientPickerDelegateImpl +import com.android.messaging.ui.conversation.audio.delegate.ConversationAudioRecordingDelegate +import com.android.messaging.ui.conversation.audio.delegate.ConversationAudioRecordingDelegateImpl +import com.android.messaging.ui.conversation.composer.delegate.ConversationComposerAttachmentsDelegate +import com.android.messaging.ui.conversation.composer.delegate.ConversationComposerAttachmentsDelegateImpl +import com.android.messaging.ui.conversation.composer.delegate.ConversationDraftDelegate +import com.android.messaging.ui.conversation.composer.delegate.ConversationDraftDelegateImpl +import com.android.messaging.ui.conversation.focus.delegate.ConversationFocusDelegate +import com.android.messaging.ui.conversation.focus.delegate.ConversationFocusDelegateImpl +import com.android.messaging.ui.conversation.mediapicker.delegate.ConversationMediaPickerDelegate +import com.android.messaging.ui.conversation.mediapicker.delegate.ConversationMediaPickerDelegateImpl +import com.android.messaging.ui.conversation.messages.delegate.ConversationMessageSelectionDelegate +import com.android.messaging.ui.conversation.messages.delegate.ConversationMessageSelectionDelegateImpl +import com.android.messaging.ui.conversation.messages.delegate.ConversationMessagesDelegate +import com.android.messaging.ui.conversation.messages.delegate.ConversationMessagesDelegateImpl +import com.android.messaging.ui.conversation.metadata.delegate.ConversationMetadataDelegate +import com.android.messaging.ui.conversation.metadata.delegate.ConversationMetadataDelegateImpl +import com.android.messaging.ui.conversation.recipientpicker.delegate.RecipientPickerDelegate +import com.android.messaging.ui.conversation.recipientpicker.delegate.RecipientPickerDelegateImpl import dagger.Binds import dagger.Module import dagger.hilt.InstallIn diff --git a/src/com/android/messaging/ui/AttachmentPreview.java b/src/com/android/messaging/ui/AttachmentPreview.java deleted file mode 100644 index f4465c46..00000000 --- a/src/com/android/messaging/ui/AttachmentPreview.java +++ /dev/null @@ -1,343 +0,0 @@ -/* - * Copyright (C) 2015 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.messaging.ui; - -import android.animation.Animator; -import android.animation.ObjectAnimator; -import android.content.Context; -import android.graphics.Rect; -import android.os.Handler; -import android.os.Looper; -import android.util.AttributeSet; -import android.view.LayoutInflater; -import android.view.View; -import android.widget.FrameLayout; -import android.widget.ImageButton; -import android.widget.ScrollView; - -import com.android.messaging.R; -import com.android.messaging.annotation.VisibleForAnimation; -import com.android.messaging.datamodel.data.DraftMessageData; -import com.android.messaging.datamodel.data.MediaPickerMessagePartData; -import com.android.messaging.datamodel.data.MessagePartData; -import com.android.messaging.datamodel.data.PendingAttachmentData; -import com.android.messaging.ui.MultiAttachmentLayout.OnAttachmentClickListener; -import com.android.messaging.ui.animation.PopupTransitionAnimation; -import com.android.messaging.ui.conversation.ComposeMessageView; -import com.android.messaging.ui.conversation.ConversationFragment; -import com.android.messaging.util.Assert; -import com.android.messaging.util.ThreadUtil; -import com.android.messaging.util.UiUtils; - -import java.util.ArrayList; -import java.util.List; - -public class AttachmentPreview extends ScrollView implements OnAttachmentClickListener { - private FrameLayout mAttachmentView; - private ComposeMessageView mComposeMessageView; - private ImageButton mCloseButton; - private int mAnimatedHeight = -1; - private Animator mCloseGapAnimator; - private boolean mPendingFirstUpdate; - private Handler mHandler; - private Runnable mHideRunnable; - private boolean mPendingHideCanceled; - - private PopupTransitionAnimation mPopupTransitionAnimation; - - private static final int CLOSE_BUTTON_REVEAL_STAGGER_MILLIS = 300; - - public AttachmentPreview(final Context context, final AttributeSet attrs) { - super(context, attrs); - mHandler = new Handler(Looper.getMainLooper()); - } - - @Override - protected void onFinishInflate() { - super.onFinishInflate(); - mCloseButton = (ImageButton) findViewById(R.id.close_button); - mCloseButton.setOnClickListener(new OnClickListener() { - @Override - public void onClick(final View view) { - mComposeMessageView.clearAttachments(); - } - }); - - mAttachmentView = (FrameLayout) findViewById(R.id.attachment_view); - - // The attachment preview is a scroll view so that it can show the bottom portion of the - // attachment whenever the space is tight (e.g. when in landscape mode). Per design - // request we'd like to make the attachment view always scrolled to the bottom. - addOnLayoutChangeListener(new OnLayoutChangeListener() { - @Override - public void onLayoutChange(final View v, final int left, final int top, final int right, - final int bottom, final int oldLeft, final int oldTop, final int oldRight, - final int oldBottom) { - post(new Runnable() { - @Override - public void run() { - final int childCount = getChildCount(); - if (childCount > 0) { - final View lastChild = getChildAt(childCount - 1); - scrollTo(getScrollX(), lastChild.getBottom() - getHeight()); - } - } - }); - } - }); - mPendingFirstUpdate = true; - } - - public void setComposeMessageView(final ComposeMessageView composeMessageView) { - mComposeMessageView = composeMessageView; - } - - @Override - protected void onMeasure(final int widthMeasureSpec, final int heightMeasureSpec) { - super.onMeasure(widthMeasureSpec, heightMeasureSpec); - if (mAnimatedHeight >= 0) { - setMeasuredDimension(getMeasuredWidth(), mAnimatedHeight); - } - } - - private void cancelPendingHide() { - mPendingHideCanceled = true; - } - - public void hideAttachmentPreview() { - if (getVisibility() != GONE) { - UiUtils.revealOrHideViewWithAnimation(mCloseButton, GONE, - null /* onFinishRunnable */); - startCloseGapAnimationOnAttachmentClear(); - - if (mAttachmentView.getChildCount() > 0) { - mPendingHideCanceled = false; - final View viewToHide = mAttachmentView.getChildCount() > 1 ? - mAttachmentView : mAttachmentView.getChildAt(0); - UiUtils.revealOrHideViewWithAnimation(viewToHide, INVISIBLE, - new Runnable() { - @Override - public void run() { - // Only hide if we are didn't get overruled by showing - if (!mPendingHideCanceled) { - stopPopupAnimation(); - mAttachmentView.removeAllViews(); - setVisibility(GONE); - } - } - }); - } else { - mAttachmentView.removeAllViews(); - setVisibility(GONE); - } - } - } - - // returns true if we have attachments - public boolean onAttachmentsChanged(final DraftMessageData draftMessageData) { - final boolean isFirstUpdate = mPendingFirstUpdate; - final List attachments = draftMessageData.getReadOnlyAttachments(); - final List pendingAttachments = - draftMessageData.getReadOnlyPendingAttachments(); - - // Any change in attachments would invalidate the animated height animation. - cancelCloseGapAnimation(); - mPendingFirstUpdate = false; - - final int combinedAttachmentCount = attachments.size() + pendingAttachments.size(); - mCloseButton.setContentDescription(getResources() - .getQuantityString(R.plurals.attachment_preview_close_content_description, - combinedAttachmentCount)); - if (combinedAttachmentCount == 0) { - mHideRunnable = new Runnable() { - @Override - public void run() { - mHideRunnable = null; - // Only start the hiding if there are still no attachments - if (attachments.size() + pendingAttachments.size() == 0) { - hideAttachmentPreview(); - } - } - }; - if (draftMessageData.isSending()) { - // Wait to hide until the message is ready to start animating - // We'll execute immediately when the animation triggers - mHandler.postDelayed(mHideRunnable, - ConversationFragment.MESSAGE_ANIMATION_MAX_WAIT); - } else { - // Run immediately when clearing attachments - mHideRunnable.run(); - } - return false; - } - - cancelPendingHide(); // We're showing - if (getVisibility() != VISIBLE) { - setVisibility(VISIBLE); - mAttachmentView.setVisibility(VISIBLE); - - // Don't animate in the close button if this is the first update after view creation. - // This is the initial draft load from database for pre-existing drafts. - if (!isFirstUpdate) { - // Reveal the close button after the view animates in. - mCloseButton.setVisibility(INVISIBLE); - ThreadUtil.getMainThreadHandler().postDelayed(new Runnable() { - @Override - public void run() { - UiUtils.revealOrHideViewWithAnimation(mCloseButton, VISIBLE, - null /* onFinishRunnable */); - } - }, UiUtils.MEDIAPICKER_TRANSITION_DURATION + CLOSE_BUTTON_REVEAL_STAGGER_MILLIS); - } - } - - // Merge the pending attachment list with real attachment. Design would prefer these be - // in LIFO order user can see added images past the 5th one but we also want them to be in - // order and we want it to be WYSIWYG. - final List combinedAttachments = new ArrayList<>(); - combinedAttachments.addAll(attachments); - combinedAttachments.addAll(pendingAttachments); - - final LayoutInflater layoutInflater = LayoutInflater.from(getContext()); - if (combinedAttachmentCount > 1) { - MultiAttachmentLayout multiAttachmentLayout = null; - Rect transitionRect = null; - if (mAttachmentView.getChildCount() > 0) { - final View firstChild = mAttachmentView.getChildAt(0); - if (firstChild instanceof MultiAttachmentLayout) { - Assert.equals(1, mAttachmentView.getChildCount()); - multiAttachmentLayout = (MultiAttachmentLayout) firstChild; - multiAttachmentLayout.bindAttachments(combinedAttachments, - null /* transitionRect */, combinedAttachmentCount); - } else { - transitionRect = new Rect(firstChild.getLeft(), firstChild.getTop(), - firstChild.getRight(), firstChild.getBottom()); - } - } - if (multiAttachmentLayout == null) { - multiAttachmentLayout = AttachmentPreviewFactory.createMultiplePreview( - getContext(), this); - multiAttachmentLayout.bindAttachments(combinedAttachments, transitionRect, - combinedAttachmentCount); - mAttachmentView.removeAllViews(); - mAttachmentView.addView(multiAttachmentLayout); - } - } else { - final MessagePartData attachment = combinedAttachments.get(0); - boolean shouldAnimate = true; - if (mAttachmentView.getChildCount() > 0) { - // If we are going from N->1 attachments, try to use the current bounds - // bounds as the starting rect. - shouldAnimate = false; - final View firstChild = mAttachmentView.getChildAt(0); - if (firstChild instanceof MultiAttachmentLayout && - attachment instanceof MediaPickerMessagePartData) { - final View leftoverView = ((MultiAttachmentLayout) firstChild) - .findViewForAttachment(attachment); - if (leftoverView != null) { - final Rect currentRect = UiUtils.getMeasuredBoundsOnScreen(leftoverView); - if (!currentRect.isEmpty() && - attachment instanceof MediaPickerMessagePartData) { - ((MediaPickerMessagePartData) attachment).setStartRect(currentRect); - shouldAnimate = true; - } - } - } - } - mAttachmentView.removeAllViews(); - final View attachmentView = AttachmentPreviewFactory.createAttachmentPreview( - layoutInflater, attachment, mAttachmentView, - AttachmentPreviewFactory.TYPE_SINGLE, true /* startImageRequest */, this); - if (attachmentView != null) { - mAttachmentView.addView(attachmentView); - if (shouldAnimate) { - tryAnimateViewIn(attachment, attachmentView); - } - } - } - return true; - } - - public void onMessageAnimationStart() { - if (mHideRunnable == null) { - return; - } - - // Run the hide animation at the same time as the message animation - mHandler.removeCallbacks(mHideRunnable); - setVisibility(View.INVISIBLE); - mHideRunnable.run(); - } - - private void tryAnimateViewIn(final MessagePartData attachmentData, final View view) { - if (attachmentData instanceof MediaPickerMessagePartData) { - final Rect startRect = ((MediaPickerMessagePartData) attachmentData).getStartRect(); - stopPopupAnimation(); - mPopupTransitionAnimation = new PopupTransitionAnimation(startRect, view); - mPopupTransitionAnimation.startAfterLayoutComplete(); - } - } - - private void stopPopupAnimation() { - if (mPopupTransitionAnimation != null) { - mPopupTransitionAnimation.cancel(); - mPopupTransitionAnimation = null; - } - } - - @VisibleForAnimation - public void setAnimatedHeight(final int animatedHeight) { - if (mAnimatedHeight != animatedHeight) { - mAnimatedHeight = animatedHeight; - requestLayout(); - } - } - - /** - * Kicks off an animation to animate the layout change for closing the gap between the - * message list and the compose message box when the attachments are cleared. - */ - private void startCloseGapAnimationOnAttachmentClear() { - // Cancel existing animation. - cancelCloseGapAnimation(); - mCloseGapAnimator = ObjectAnimator.ofInt(this, "animatedHeight", getHeight(), 0); - mCloseGapAnimator.start(); - } - - private void cancelCloseGapAnimation() { - if (mCloseGapAnimator != null) { - mCloseGapAnimator.cancel(); - mCloseGapAnimator = null; - } - mAnimatedHeight = -1; - } - - @Override - public boolean onAttachmentClick(final MessagePartData attachment, - final Rect viewBoundsOnScreen, final boolean longPress) { - if (longPress) { - mComposeMessageView.onAttachmentPreviewLongClicked(); - return true; - } - - if (!(attachment instanceof PendingAttachmentData) && attachment.isImage()) { - mComposeMessageView.displayPhoto(attachment.getContentUri(), viewBoundsOnScreen); - return true; - } - return false; - } -} diff --git a/src/com/android/messaging/ui/AttachmentSaveTask.java b/src/com/android/messaging/ui/AttachmentSaveTask.java new file mode 100644 index 00000000..cd501b5d --- /dev/null +++ b/src/com/android/messaging/ui/AttachmentSaveTask.java @@ -0,0 +1,150 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.ui; + +import android.app.DownloadManager; +import android.content.Context; +import android.content.Intent; +import android.net.Uri; +import android.os.Environment; + +import com.android.messaging.R; +import com.android.messaging.util.ContentType; +import com.android.messaging.util.SafeAsyncTask; +import com.android.messaging.util.UiUtils; +import com.android.messaging.util.UriUtil; + +import java.io.File; +import java.util.ArrayList; +import java.util.List; + +public class AttachmentSaveTask extends SafeAsyncTask { + private final Context mContext; + private final List mAttachmentsToSave = new ArrayList<>(); + + public AttachmentSaveTask(final Context context, final Uri contentUri, + final String contentType) { + mContext = context; + addAttachmentToSave(contentUri, contentType); + } + + public AttachmentSaveTask(final Context context) { + mContext = context; + } + + public void addAttachmentToSave(final Uri contentUri, final String contentType) { + mAttachmentsToSave.add(new AttachmentToSave(contentUri, contentType)); + } + + public int getAttachmentCount() { + return mAttachmentsToSave.size(); + } + + @Override + protected Void doInBackgroundTimed(final Void... arg) { + final File appDir = new File(Environment.getExternalStoragePublicDirectory( + Environment.DIRECTORY_PICTURES), + mContext.getResources().getString(R.string.app_name)); + final File downloadDir = Environment.getExternalStoragePublicDirectory( + Environment.DIRECTORY_DOWNLOADS); + for (final AttachmentToSave attachment : mAttachmentsToSave) { + final boolean isImageOrVideo = ContentType.isImageType(attachment.contentType) + || ContentType.isVideoType(attachment.contentType); + attachment.persistedUri = UriUtil.persistContent(attachment.uri, + isImageOrVideo ? appDir : downloadDir, attachment.contentType); + } + return null; + } + + @Override + protected void onPostExecute(final Void result) { + int failCount = 0; + int imageCount = 0; + int videoCount = 0; + int otherCount = 0; + for (final AttachmentToSave attachment : mAttachmentsToSave) { + if (attachment.persistedUri == null) { + failCount++; + continue; + } + + final Intent scanFileIntent = new Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE); + scanFileIntent.setData(attachment.persistedUri); + mContext.sendBroadcast(scanFileIntent); + + if (ContentType.isImageType(attachment.contentType)) { + imageCount++; + } else if (ContentType.isVideoType(attachment.contentType)) { + videoCount++; + } else { + otherCount++; + final DownloadManager downloadManager = + (DownloadManager) mContext.getSystemService(Context.DOWNLOAD_SERVICE); + final String filePath = attachment.persistedUri.getPath(); + final File file = new File(filePath); + + if (file.exists()) { + downloadManager.addCompletedDownload( + file.getName() /* title */, + mContext.getString( + R.string.attachment_file_description) /* description */, + true /* isMediaScannerScannable */, + attachment.contentType, + file.getAbsolutePath(), + file.length(), + false /* showNotification */); + } + } + } + + final String message; + if (failCount > 0) { + message = mContext.getResources().getQuantityString( + R.plurals.attachment_save_error, failCount, failCount); + } else { + int messageId = R.plurals.attachments_saved; + if (otherCount > 0) { + if (imageCount + videoCount == 0) { + messageId = R.plurals.attachments_saved_to_downloads; + } + } else { + if (videoCount == 0) { + messageId = R.plurals.photos_saved_to_album; + } else if (imageCount == 0) { + messageId = R.plurals.videos_saved_to_album; + } else { + messageId = R.plurals.attachments_saved_to_album; + } + } + final String appName = mContext.getResources().getString(R.string.app_name); + final int count = imageCount + videoCount + otherCount; + message = mContext.getResources().getQuantityString(messageId, count, count, appName); + } + UiUtils.showToastAtBottom(message); + } + + private static class AttachmentToSave { + public final Uri uri; + public final String contentType; + public Uri persistedUri; + + AttachmentToSave(final Uri uri, final String contentType) { + this.uri = uri; + this.contentType = contentType; + } + } +} diff --git a/src/com/android/messaging/ui/UIIntentsImpl.java b/src/com/android/messaging/ui/UIIntentsImpl.java index f1392d6d..9c5d18f0 100644 --- a/src/com/android/messaging/ui/UIIntentsImpl.java +++ b/src/com/android/messaging/ui/UIIntentsImpl.java @@ -50,8 +50,7 @@ import com.android.messaging.ui.appsettings.PerSubscriptionSettingsActivity; import com.android.messaging.ui.appsettings.SettingsActivity; import com.android.messaging.ui.attachmentchooser.AttachmentChooserActivity; -import com.android.messaging.ui.conversation.v2.ConversationActivity; -//import com.android.messaging.ui.conversation.ConversationActivity; +import com.android.messaging.ui.conversation.ConversationActivity; import com.android.messaging.ui.conversation.LaunchConversationActivity; import com.android.messaging.ui.conversationlist.ArchivedConversationListActivity; import com.android.messaging.ui.conversationlist.ConversationListActivity; diff --git a/src/com/android/messaging/ui/conversation/ComposeMessageView.java b/src/com/android/messaging/ui/conversation/ComposeMessageView.java deleted file mode 100644 index e187f10c..00000000 --- a/src/com/android/messaging/ui/conversation/ComposeMessageView.java +++ /dev/null @@ -1,1016 +0,0 @@ -/* - * Copyright (C) 2015 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.android.messaging.ui.conversation; - -import android.content.Context; -import android.content.res.Resources; -import android.graphics.Rect; -import android.net.Uri; -import android.os.Bundle; -import android.text.Editable; -import android.text.Html; -import android.text.InputFilter; -import android.text.InputFilter.LengthFilter; -import android.text.TextUtils; -import android.text.TextWatcher; -import android.text.format.Formatter; -import android.util.AttributeSet; -import android.view.ContextThemeWrapper; -import android.view.KeyEvent; -import android.view.View; -import android.view.accessibility.AccessibilityEvent; -import android.view.inputmethod.EditorInfo; -import android.widget.ImageButton; -import android.widget.LinearLayout; -import android.widget.TextView; - -import com.android.messaging.Factory; -import com.android.messaging.R; -import com.android.messaging.datamodel.binding.Binding; -import com.android.messaging.datamodel.binding.BindingBase; -import com.android.messaging.datamodel.binding.ImmutableBindingRef; -import com.android.messaging.datamodel.data.ConversationData; -import com.android.messaging.datamodel.data.ConversationData.ConversationDataListener; -import com.android.messaging.datamodel.data.ConversationData.SimpleConversationDataListener; -import com.android.messaging.datamodel.data.DraftMessageData; -import com.android.messaging.datamodel.data.DraftMessageData.CheckDraftForSendTask; -import com.android.messaging.datamodel.data.DraftMessageData.CheckDraftTaskCallback; -import com.android.messaging.datamodel.data.DraftMessageData.DraftMessageDataListener; -import com.android.messaging.datamodel.data.MessageData; -import com.android.messaging.datamodel.data.MessagePartData; -import com.android.messaging.datamodel.data.ParticipantData; -import com.android.messaging.datamodel.data.PendingAttachmentData; -import com.android.messaging.datamodel.data.SubscriptionListData.SubscriptionListEntry; -import com.android.messaging.sms.MmsConfig; -import com.android.messaging.ui.AttachmentPreview; -import com.android.messaging.ui.BugleActionBarActivity; -import com.android.messaging.ui.PlainTextEditText; -import com.android.messaging.ui.conversation.ConversationInputManager.ConversationInputSink; -import com.android.messaging.util.AccessibilityUtil; -import com.android.messaging.util.Assert; -import com.android.messaging.util.AvatarUriUtil; -import com.android.messaging.util.BuglePrefs; -import com.android.messaging.util.ContentType; -import com.android.messaging.util.LogUtil; -import com.android.messaging.util.MediaUtil; -import com.android.messaging.util.SafeAsyncTask; -import com.android.messaging.util.UiUtils; -import com.android.messaging.util.UriUtil; - -import java.util.ArrayList; -import java.util.Collection; -import java.util.List; - -import androidx.appcompat.app.ActionBar; - -/** - * This view contains the UI required to generate and send messages. - */ -public class ComposeMessageView extends LinearLayout - implements TextView.OnEditorActionListener, DraftMessageDataListener, TextWatcher, - ConversationInputSink { - - public interface IComposeMessageViewHost extends - DraftMessageData.DraftMessageSubscriptionDataProvider { - void sendMessage(MessageData message); - void onComposeEditTextFocused(); - void onAttachmentsCleared(); - void onAttachmentsChanged(final boolean haveAttachments); - void displayPhoto(Uri photoUri, Rect imageBounds, boolean isDraft); - void promptForSelfPhoneNumber(); - boolean isReadyForAction(); - void warnOfMissingActionConditions(final boolean sending, - final Runnable commandToRunAfterActionConditionResolved); - void warnOfExceedingMessageLimit(final boolean showAttachmentChooser, - boolean tooManyVideos); - void notifyOfAttachmentLoadFailed(); - void showAttachmentChooser(); - boolean shouldShowSubjectEditor(); - boolean shouldHideAttachmentsWhenSimSelectorShown(); - Uri getSelfSendButtonIconUri(); - int overrideCounterColor(); - int getAttachmentsClearedFlags(); - } - - public static final int CODEPOINTS_REMAINING_BEFORE_COUNTER_SHOWN = 10; - - // There is no draft and there is no need for the SIM selector - private static final int SEND_WIDGET_MODE_SELF_AVATAR = 1; - // There is no draft but we need to show the SIM selector - private static final int SEND_WIDGET_MODE_SIM_SELECTOR = 2; - // There is a draft - private static final int SEND_WIDGET_MODE_SEND_BUTTON = 3; - - private PlainTextEditText mComposeEditText; - private PlainTextEditText mComposeSubjectText; - private TextView mMessageBodySize; - private TextView mMmsIndicator; - private SimIconView mSelfSendIcon; - private ImageButton mSendButton; - private View mSubjectView; - private ImageButton mDeleteSubjectButton; - private AttachmentPreview mAttachmentPreview; - private ImageButton mAttachMediaButton; - - private final Binding mBinding; - private IComposeMessageViewHost mHost; - private final Context mOriginalContext; - private int mSendWidgetMode = SEND_WIDGET_MODE_SELF_AVATAR; - - // Shared data model object binding from the conversation. - private ImmutableBindingRef mConversationDataModel; - - // Centrally manages all the mutual exclusive UI components accepting user input, i.e. - // media picker, IME keyboard and SIM selector. - private ConversationInputManager mInputManager; - - private final ConversationDataListener mDataListener = new SimpleConversationDataListener() { - @Override - public void onConversationMetadataUpdated(ConversationData data) { - mConversationDataModel.ensureBound(data); - updateVisualsOnDraftChanged(); - } - - @Override - public void onConversationParticipantDataLoaded(ConversationData data) { - mConversationDataModel.ensureBound(data); - updateVisualsOnDraftChanged(); - } - - @Override - public void onSubscriptionListDataLoaded(ConversationData data) { - mConversationDataModel.ensureBound(data); - updateOnSelfSubscriptionChange(); - updateVisualsOnDraftChanged(); - } - }; - - public ComposeMessageView(final Context context, final AttributeSet attrs) { - super(new ContextThemeWrapper(context, R.style.ColorAccentBlueOverrideStyle), attrs); - mOriginalContext = context; - mBinding = BindingBase.createBinding(this); - } - - /** - * Host calls this to bind view to DraftMessageData object - */ - public void bind(final DraftMessageData data, final IComposeMessageViewHost host) { - mHost = host; - mBinding.bind(data); - data.addListener(this); - data.setSubscriptionDataProvider(host); - - final int counterColor = mHost.overrideCounterColor(); - if (counterColor != -1) { - mMessageBodySize.setTextColor(counterColor); - } - } - - /** - * Host calls this to unbind view - */ - public void unbind() { - mBinding.unbind(); - mHost = null; - mInputManager.onDetach(); - } - - @Override - protected void onFinishInflate() { - super.onFinishInflate(); - mComposeEditText = findViewById(R.id.compose_message_text); - mComposeEditText.setOnEditorActionListener(this); - mComposeEditText.addTextChangedListener(this); - mComposeEditText.setOnFocusChangeListener(new OnFocusChangeListener() { - @Override - public void onFocusChange(final View v, final boolean hasFocus) { - if (v == mComposeEditText && hasFocus) { - mHost.onComposeEditTextFocused(); - } - } - }); - mComposeEditText.setOnClickListener(new View.OnClickListener() { - @Override - public void onClick(View arg0) { - if (mHost.shouldHideAttachmentsWhenSimSelectorShown()) { - hideSimSelector(); - } - } - }); - - // onFinishInflate() is called before self is loaded from db. We set the default text - // limit here, and apply the real limit later in updateOnSelfSubscriptionChange(). - mComposeEditText.setFilters(new InputFilter[] { - new LengthFilter(MmsConfig.get(ParticipantData.DEFAULT_SELF_SUB_ID) - .getMaxTextLimit()) }); - - mSelfSendIcon = (SimIconView) findViewById(R.id.self_send_icon); - mSelfSendIcon.setOnClickListener(new OnClickListener() { - @Override - public void onClick(View v) { - boolean shown = mInputManager.toggleSimSelector(true /* animate */, - getSelfSubscriptionListEntry()); - hideAttachmentsWhenShowingSims(shown); - } - }); - mSelfSendIcon.setOnLongClickListener(new OnLongClickListener() { - @Override - public boolean onLongClick(final View v) { - if (mHost.shouldShowSubjectEditor()) { - showSubjectEditor(); - } else { - boolean shown = mInputManager.toggleSimSelector(true /* animate */, - getSelfSubscriptionListEntry()); - hideAttachmentsWhenShowingSims(shown); - } - return true; - } - }); - - mComposeSubjectText = (PlainTextEditText) findViewById( - R.id.compose_subject_text); - // We need the listener to change the avatar to the send button when the user starts - // typing a subject without a message. - mComposeSubjectText.addTextChangedListener(this); - // onFinishInflate() is called before self is loaded from db. We set the default text - // limit here, and apply the real limit later in updateOnSelfSubscriptionChange(). - mComposeSubjectText.setFilters(new InputFilter[] { - new LengthFilter(MmsConfig.get(ParticipantData.DEFAULT_SELF_SUB_ID) - .getMaxSubjectLength())}); - - mDeleteSubjectButton = (ImageButton) findViewById(R.id.delete_subject_button); - mDeleteSubjectButton.setOnClickListener(new OnClickListener() { - @Override - public void onClick(final View clickView) { - hideSubjectEditor(); - mComposeSubjectText.setText(null); - mBinding.getData().setMessageSubject(null); - } - }); - - mSubjectView = findViewById(R.id.subject_view); - - mSendButton = (ImageButton) findViewById(R.id.send_message_button); - mSendButton.setOnClickListener(new OnClickListener() { - @Override - public void onClick(final View clickView) { - sendMessageInternal(true /* checkMessageSize */); - } - }); - mSendButton.setOnLongClickListener(new OnLongClickListener() { - @Override - public boolean onLongClick(final View arg0) { - boolean shown = mInputManager.toggleSimSelector(true /* animate */, - getSelfSubscriptionListEntry()); - hideAttachmentsWhenShowingSims(shown); - if (mHost.shouldShowSubjectEditor()) { - showSubjectEditor(); - } - return true; - } - }); - mSendButton.setAccessibilityDelegate(new AccessibilityDelegate() { - @Override - public void onPopulateAccessibilityEvent(View host, AccessibilityEvent event) { - super.onPopulateAccessibilityEvent(host, event); - // When the send button is long clicked, we want TalkBack to announce the real - // action (select SIM or edit subject), as opposed to "long press send button." - if (event.getEventType() == AccessibilityEvent.TYPE_VIEW_LONG_CLICKED) { - event.getText().clear(); - event.getText().add(getResources() - .getText(shouldShowSimSelector(mConversationDataModel.getData()) ? - R.string.send_button_long_click_description_with_sim_selector : - R.string.send_button_long_click_description_no_sim_selector)); - // Make this an announcement so TalkBack will read our custom message. - event.setEventType(AccessibilityEvent.TYPE_ANNOUNCEMENT); - } - } - }); - - mAttachMediaButton = - (ImageButton) findViewById(R.id.attach_media_button); - mAttachMediaButton.setOnClickListener(new View.OnClickListener() { - @Override - public void onClick(final View clickView) { - // Showing the media picker is treated as starting to compose the message. - mInputManager.showHideMediaPicker(true /* show */, true /* animate */); - } - }); - - mAttachmentPreview = (AttachmentPreview) findViewById(R.id.attachment_draft_view); - mAttachmentPreview.setComposeMessageView(this); - - mMessageBodySize = (TextView) findViewById(R.id.message_body_size); - mMmsIndicator = (TextView) findViewById(R.id.mms_indicator); - } - - private void hideAttachmentsWhenShowingSims(final boolean simPickerVisible) { - if (!mHost.shouldHideAttachmentsWhenSimSelectorShown()) { - return; - } - final boolean haveAttachments = mBinding.getData().hasAttachments(); - if (simPickerVisible && haveAttachments) { - mHost.onAttachmentsChanged(false); - mAttachmentPreview.hideAttachmentPreview(); - } else { - mHost.onAttachmentsChanged(haveAttachments); - mAttachmentPreview.onAttachmentsChanged(mBinding.getData()); - } - } - - public void setInputManager(final ConversationInputManager inputManager) { - mInputManager = inputManager; - } - - public void setConversationDataModel(final ImmutableBindingRef refDataModel) { - mConversationDataModel = refDataModel; - mConversationDataModel.getData().addConversationDataListener(mDataListener); - } - - ImmutableBindingRef getDraftDataModel() { - return BindingBase.createBindingReference(mBinding); - } - - // returns true if it actually shows the subject editor and false if already showing - private boolean showSubjectEditor() { - // show the subject editor - if (mSubjectView.getVisibility() == View.GONE) { - mSubjectView.setVisibility(View.VISIBLE); - mSubjectView.requestFocus(); - return true; - } - return false; - } - - private void hideSubjectEditor() { - mSubjectView.setVisibility(View.GONE); - mComposeEditText.requestFocus(); - } - - /** - * {@inheritDoc} from TextView.OnEditorActionListener - */ - @Override // TextView.OnEditorActionListener.onEditorAction - public boolean onEditorAction(final TextView view, final int actionId, final KeyEvent event) { - if (actionId == EditorInfo.IME_ACTION_SEND) { - sendMessageInternal(true /* checkMessageSize */); - return true; - } - return false; - } - - private void sendMessageInternal(final boolean checkMessageSize) { - LogUtil.i(LogUtil.BUGLE_TAG, "UI initiated message sending in conversation " + - mBinding.getData().getConversationId()); - if (mBinding.getData().isCheckingDraft()) { - // Don't send message if we are currently checking draft for sending. - LogUtil.w(LogUtil.BUGLE_TAG, "Message can't be sent: still checking draft"); - return; - } - // Check the host for pre-conditions about any action. - if (mHost.isReadyForAction()) { - mInputManager.showHideSimSelector(false /* show */, true /* animate */); - final String messageToSend = mComposeEditText.getText().toString(); - mBinding.getData().setMessageText(messageToSend); - final String subject = mComposeSubjectText.getText().toString(); - mBinding.getData().setMessageSubject(subject); - // Asynchronously check the draft against various requirements before sending. - mBinding.getData().checkDraftForAction(checkMessageSize, - mHost.getConversationSelfSubId(), new CheckDraftTaskCallback() { - @Override - public void onDraftChecked(DraftMessageData data, int result) { - mBinding.ensureBound(data); - switch (result) { - case CheckDraftForSendTask.RESULT_PASSED: - // Continue sending after check succeeded. - final MessageData message = mBinding.getData() - .prepareMessageForSending(mBinding); - if (message != null && message.hasContent()) { - playSentSound(); - mHost.sendMessage(message); - hideSubjectEditor(); - if (AccessibilityUtil.isTouchExplorationEnabled(getContext())) { - AccessibilityUtil.announceForAccessibilityCompat( - ComposeMessageView.this, null, - R.string.sending_message); - } - } - break; - - case CheckDraftForSendTask.RESULT_HAS_PENDING_ATTACHMENTS: - // Cannot send while there's still attachment(s) being loaded. - UiUtils.showToastAtBottom( - R.string.cant_send_message_while_loading_attachments); - break; - - case CheckDraftForSendTask.RESULT_NO_SELF_PHONE_NUMBER_IN_GROUP_MMS: - mHost.promptForSelfPhoneNumber(); - break; - - case CheckDraftForSendTask.RESULT_MESSAGE_OVER_LIMIT: - Assert.isTrue(checkMessageSize); - mHost.warnOfExceedingMessageLimit( - true /*sending*/, false /* tooManyVideos */); - break; - - case CheckDraftForSendTask.RESULT_VIDEO_ATTACHMENT_LIMIT_EXCEEDED: - Assert.isTrue(checkMessageSize); - mHost.warnOfExceedingMessageLimit( - true /*sending*/, true /* tooManyVideos */); - break; - - case CheckDraftForSendTask.RESULT_SIM_NOT_READY: - // Cannot send if there is no active subscription - UiUtils.showToastAtBottom( - R.string.cant_send_message_without_active_subscription); - break; - - default: - break; - } - } - }, mBinding); - } else { - mHost.warnOfMissingActionConditions(true /*sending*/, - new Runnable() { - @Override - public void run() { - sendMessageInternal(checkMessageSize); - } - - }); - } - } - - public static void playSentSound() { - // Check if this setting is enabled before playing - final BuglePrefs prefs = BuglePrefs.getApplicationPrefs(); - final Context context = Factory.get().getApplicationContext(); - final String prefKey = context.getString(R.string.send_sound_pref_key); - final boolean defaultValue = context.getResources().getBoolean( - R.bool.send_sound_pref_default); - if (!prefs.getBoolean(prefKey, defaultValue)) { - return; - } - MediaUtil.get().playSound(context, R.raw.message_sent, null /* completionListener */); - } - - /** - * {@inheritDoc} from DraftMessageDataListener - */ - @Override // From DraftMessageDataListener - public void onDraftChanged(final DraftMessageData data, final int changeFlags) { - // As this is called asynchronously when message read check bound before updating text - mBinding.ensureBound(data); - - // We have to cache the values of the DraftMessageData because when we set - // mComposeEditText, its onTextChanged calls updateVisualsOnDraftChanged, - // which immediately reloads the text from the subject and message fields and replaces - // what's in the DraftMessageData. - - final String subject = data.getMessageSubject(); - final String message = data.getMessageText(); - - boolean hasAttachmentsChanged = false; - - if ((changeFlags & DraftMessageData.MESSAGE_SUBJECT_CHANGED) == - DraftMessageData.MESSAGE_SUBJECT_CHANGED) { - mComposeSubjectText.setText(subject); - - // Set the cursor selection to the end since setText resets it to the start - mComposeSubjectText.setSelection(mComposeSubjectText.getText().length()); - } - - if ((changeFlags & DraftMessageData.MESSAGE_TEXT_CHANGED) == - DraftMessageData.MESSAGE_TEXT_CHANGED) { - mComposeEditText.setText(message); - - // Set the cursor selection to the end since setText resets it to the start - mComposeEditText.setSelection(mComposeEditText.getText().length()); - } - - if ((changeFlags & DraftMessageData.ATTACHMENTS_CHANGED) == - DraftMessageData.ATTACHMENTS_CHANGED) { - final boolean haveAttachments = mAttachmentPreview.onAttachmentsChanged(data); - mHost.onAttachmentsChanged(haveAttachments); - hasAttachmentsChanged = true; - } - - if ((changeFlags & DraftMessageData.SELF_CHANGED) == DraftMessageData.SELF_CHANGED) { - updateOnSelfSubscriptionChange(); - } - updateVisualsOnDraftChanged(hasAttachmentsChanged); - } - - @Override // From DraftMessageDataListener - public void onDraftAttachmentLimitReached(final DraftMessageData data) { - mBinding.ensureBound(data); - mHost.warnOfExceedingMessageLimit(false /* sending */, false /* tooManyVideos */); - } - - private void updateOnSelfSubscriptionChange() { - // Refresh the length filters according to the selected self's MmsConfig. - mComposeEditText.setFilters(new InputFilter[] { - new LengthFilter(MmsConfig.get(mBinding.getData().getSelfSubId()) - .getMaxTextLimit()) }); - mComposeSubjectText.setFilters(new InputFilter[] { - new LengthFilter(MmsConfig.get(mBinding.getData().getSelfSubId()) - .getMaxSubjectLength())}); - } - - @Override - public void onMediaItemsSelected(final Collection items) { - mBinding.getData().addAttachments(items); - announceMediaItemState(true /*isSelected*/); - } - - @Override - public void onMediaItemsUnselected(final MessagePartData item) { - mBinding.getData().removeAttachment(item); - announceMediaItemState(false /*isSelected*/); - } - - @Override - public void onPendingAttachmentAdded(final PendingAttachmentData pendingItem) { - mBinding.getData().addPendingAttachment(pendingItem, mBinding); - resumeComposeMessage(); - } - - private void announceMediaItemState(final boolean isSelected) { - final Resources res = getContext().getResources(); - final String announcement = isSelected ? res.getString( - R.string.mediapicker_gallery_item_selected_content_description) : - res.getString(R.string.mediapicker_gallery_item_unselected_content_description); - AccessibilityUtil.announceForAccessibilityCompat( - this, null, announcement); - } - - private void announceAttachmentState() { - if (AccessibilityUtil.isTouchExplorationEnabled(getContext())) { - int attachmentCount = mBinding.getData().getReadOnlyAttachments().size() - + mBinding.getData().getReadOnlyPendingAttachments().size(); - final String announcement = getContext().getResources().getQuantityString( - R.plurals.attachment_changed_accessibility_announcement, - attachmentCount, attachmentCount); - AccessibilityUtil.announceForAccessibilityCompat( - this, null, announcement); - } - } - - @Override - public void resumeComposeMessage() { - mComposeEditText.requestFocus(); - mInputManager.showHideImeKeyboard(true, true); - announceAttachmentState(); - } - - public void clearAttachments() { - mBinding.getData().clearAttachments(mHost.getAttachmentsClearedFlags()); - mHost.onAttachmentsCleared(); - } - - public void requestDraftMessage(boolean clearLocalDraft) { - mBinding.getData().loadFromStorage(mBinding, null, clearLocalDraft); - } - - public void setDraftMessage(final MessageData message) { - mBinding.getData().loadFromStorage(mBinding, message, false); - } - - public void writeDraftMessage() { - final String messageText = mComposeEditText.getText().toString(); - mBinding.getData().setMessageText(messageText); - - final String subject = mComposeSubjectText.getText().toString(); - mBinding.getData().setMessageSubject(subject); - - mBinding.getData().saveToStorage(mBinding); - } - - private void updateConversationSelfId(final String selfId, final boolean notify) { - mBinding.getData().setSelfId(selfId, notify); - } - - private Uri getSelfSendButtonIconUri() { - final Uri overridenSelfUri = mHost.getSelfSendButtonIconUri(); - if (overridenSelfUri != null) { - return overridenSelfUri; - } - final SubscriptionListEntry subscriptionListEntry = getSelfSubscriptionListEntry(); - - if (subscriptionListEntry != null) { - return subscriptionListEntry.selectedIconUri; - } - - // Fall back to default self-avatar in the base case. - final ParticipantData self = mConversationDataModel.getData().getDefaultSelfParticipant(); - return self == null ? null : AvatarUriUtil.createAvatarUri(self); - } - - private SubscriptionListEntry getSelfSubscriptionListEntry() { - return mConversationDataModel.getData().getSubscriptionEntryForSelfParticipant( - mBinding.getData().getSelfId(), false /* excludeDefault */); - } - - private boolean isDataLoadedForMessageSend() { - // Check data loading prerequisites for sending a message. - return mConversationDataModel != null && mConversationDataModel.isBound() && - mConversationDataModel.getData().getParticipantsLoaded(); - } - - private static class AsyncUpdateMessageBodySizeTask - extends SafeAsyncTask, Void, Long> { - - private final Context mContext; - private final TextView mSizeTextView; - - public AsyncUpdateMessageBodySizeTask(final Context context, final TextView tv) { - mContext = context; - mSizeTextView = tv; - } - - @Override - protected Long doInBackgroundTimed(final List... params) { - final List attachments = params[0]; - long totalSize = 0; - for (final MessagePartData attachment : attachments) { - final Uri contentUri = attachment.getContentUri(); - if (contentUri != null) { - totalSize += UriUtil.getContentSize(attachment.getContentUri()); - } - } - return totalSize; - } - - @Override - protected void onPostExecute(Long size) { - if (mSizeTextView != null) { - mSizeTextView.setText(Formatter.formatFileSize(mContext, size)); - mSizeTextView.setVisibility(View.VISIBLE); - } - } - } - - private void updateVisualsOnDraftChanged() { - updateVisualsOnDraftChanged(false); - } - - private void updateVisualsOnDraftChanged(boolean hasAttachmentsChanged) { - final String messageText = mComposeEditText.getText().toString(); - final DraftMessageData draftMessageData = mBinding.getData(); - draftMessageData.setMessageText(messageText); - - final String subject = mComposeSubjectText.getText().toString(); - draftMessageData.setMessageSubject(subject); - if (!TextUtils.isEmpty(subject)) { - mSubjectView.setVisibility(View.VISIBLE); - } - - final boolean hasMessageText = (TextUtils.getTrimmedLength(messageText) > 0); - final boolean hasSubject = (TextUtils.getTrimmedLength(subject) > 0); - final boolean hasWorkingDraft = hasMessageText || hasSubject || - mBinding.getData().hasAttachments(); - - final List attachments = - new ArrayList(draftMessageData.getReadOnlyAttachments()); - if (draftMessageData.getIsMms()) { // MMS case - if (draftMessageData.hasAttachments()) { - if (hasAttachmentsChanged) { - // Calculate message attachments size and show it. - new AsyncUpdateMessageBodySizeTask(getContext(), mMessageBodySize) - .executeOnThreadPool(attachments, null, null); - } else { - // No update. Just show previous size. - mMessageBodySize.setVisibility(View.VISIBLE); - } - } else { - mMessageBodySize.setVisibility(View.INVISIBLE); - } - } else { // SMS case - // Update the SMS text counter. - final int messageCount = draftMessageData.getNumMessagesToBeSent(); - final int codePointsRemaining = - draftMessageData.getCodePointsRemainingInCurrentMessage(); - // Show the counter only if we are going to send more than one message OR we are getting - // close. - if (messageCount > 1 - || codePointsRemaining <= CODEPOINTS_REMAINING_BEFORE_COUNTER_SHOWN) { - // Update the remaining characters and number of messages required. - final String counterText = - messageCount > 1 - ? codePointsRemaining + " / " + messageCount - : String.valueOf(codePointsRemaining); - mMessageBodySize.setText(counterText); - mMessageBodySize.setVisibility(View.VISIBLE); - } else { - mMessageBodySize.setVisibility(View.INVISIBLE); - } - } - - // Update the send message button. Self icon uri might be null if self participant data - // and/or conversation metadata hasn't been loaded by the host. - final Uri selfSendButtonUri = getSelfSendButtonIconUri(); - int sendWidgetMode = SEND_WIDGET_MODE_SELF_AVATAR; - if (selfSendButtonUri != null) { - if (hasWorkingDraft && isDataLoadedForMessageSend()) { - UiUtils.revealOrHideViewWithAnimation(mSendButton, VISIBLE, null); - if (isOverriddenAvatarAGroup()) { - // If the host has overriden the avatar to show a group avatar where the - // send button sits, we have to hide the group avatar because it can be larger - // than the send button and pieces of the avatar will stick out from behind - // the send button. - UiUtils.revealOrHideViewWithAnimation(mSelfSendIcon, GONE, null); - } - mMmsIndicator.setVisibility(draftMessageData.getIsMms() ? VISIBLE : INVISIBLE); - sendWidgetMode = SEND_WIDGET_MODE_SEND_BUTTON; - } else { - mSelfSendIcon.setImageResourceUri(selfSendButtonUri); - if (isOverriddenAvatarAGroup()) { - UiUtils.revealOrHideViewWithAnimation(mSelfSendIcon, VISIBLE, null); - } - UiUtils.revealOrHideViewWithAnimation(mSendButton, GONE, null); - mMmsIndicator.setVisibility(INVISIBLE); - if (shouldShowSimSelector(mConversationDataModel.getData())) { - sendWidgetMode = SEND_WIDGET_MODE_SIM_SELECTOR; - } - } - } else { - mSelfSendIcon.setImageResourceUri(null); - } - - if (mSendWidgetMode != sendWidgetMode || sendWidgetMode == SEND_WIDGET_MODE_SIM_SELECTOR) { - setSendButtonAccessibility(sendWidgetMode); - mSendWidgetMode = sendWidgetMode; - } - - // Update the text hint on the message box depending on the attachment type. - final int attachmentCount = attachments.size(); - if (attachmentCount == 0) { - final SubscriptionListEntry subscriptionListEntry = - mConversationDataModel.getData().getSubscriptionEntryForSelfParticipant( - mBinding.getData().getSelfId(), false /* excludeDefault */); - if (subscriptionListEntry == null) { - mComposeEditText.setHint(R.string.compose_message_view_hint_text); - } else { - mComposeEditText.setHint(Html.fromHtml(getResources().getString( - R.string.compose_message_view_hint_text_multi_sim, - subscriptionListEntry.displayName))); - } - } else { - int type = -1; - for (final MessagePartData attachment : attachments) { - int newType; - if (attachment.isImage()) { - newType = ContentType.TYPE_IMAGE; - } else if (attachment.isAudio()) { - newType = ContentType.TYPE_AUDIO; - } else if (attachment.isVideo()) { - newType = ContentType.TYPE_VIDEO; - } else if (attachment.isVCard()) { - newType = ContentType.TYPE_VCARD; - } else { - newType = ContentType.TYPE_OTHER; - } - - if (type == -1) { - type = newType; - } else if (type != newType || type == ContentType.TYPE_OTHER) { - type = ContentType.TYPE_OTHER; - break; - } - } - - switch (type) { - case ContentType.TYPE_IMAGE: - mComposeEditText.setHint(getResources().getQuantityString( - R.plurals.compose_message_view_hint_text_photo, attachmentCount)); - break; - - case ContentType.TYPE_AUDIO: - mComposeEditText.setHint(getResources().getQuantityString( - R.plurals.compose_message_view_hint_text_audio, attachmentCount)); - break; - - case ContentType.TYPE_VIDEO: - mComposeEditText.setHint(getResources().getQuantityString( - R.plurals.compose_message_view_hint_text_video, attachmentCount)); - break; - - case ContentType.TYPE_VCARD: - mComposeEditText.setHint(getResources().getQuantityString( - R.plurals.compose_message_view_hint_text_vcard, attachmentCount)); - break; - - case ContentType.TYPE_OTHER: - mComposeEditText.setHint(getResources().getQuantityString( - R.plurals.compose_message_view_hint_text_attachments, attachmentCount)); - break; - - default: - Assert.fail("Unsupported attachment type!"); - break; - } - } - } - - private void setSendButtonAccessibility(final int sendWidgetMode) { - switch (sendWidgetMode) { - case SEND_WIDGET_MODE_SELF_AVATAR: - // No send button and no SIM selector; the self send button is no longer - // important for accessibility. - mSelfSendIcon.setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_NO); - mSelfSendIcon.setContentDescription(null); - mSendButton.setVisibility(View.GONE); - setSendWidgetAccessibilityTraversalOrder(SEND_WIDGET_MODE_SELF_AVATAR); - break; - - case SEND_WIDGET_MODE_SIM_SELECTOR: - mSelfSendIcon.setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_YES); - mSelfSendIcon.setContentDescription(getSimContentDescription()); - setSendWidgetAccessibilityTraversalOrder(SEND_WIDGET_MODE_SIM_SELECTOR); - break; - - case SEND_WIDGET_MODE_SEND_BUTTON: - mMmsIndicator.setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_NO); - mMmsIndicator.setContentDescription(null); - setSendWidgetAccessibilityTraversalOrder(SEND_WIDGET_MODE_SEND_BUTTON); - break; - } - } - - private String getSimContentDescription() { - final SubscriptionListEntry sub = getSelfSubscriptionListEntry(); - if (sub != null) { - return getResources().getString( - R.string.sim_selector_button_content_description_with_selection, - sub.displayName); - } else { - return getResources().getString( - R.string.sim_selector_button_content_description); - } - } - - // Set accessibility traversal order of the components in the send widget. - private void setSendWidgetAccessibilityTraversalOrder(final int mode) { - mAttachMediaButton.setAccessibilityTraversalBefore(R.id.compose_message_text); - switch (mode) { - case SEND_WIDGET_MODE_SIM_SELECTOR: - mComposeEditText.setAccessibilityTraversalBefore(R.id.self_send_icon); - break; - case SEND_WIDGET_MODE_SEND_BUTTON: - mComposeEditText.setAccessibilityTraversalBefore(R.id.send_message_button); - break; - default: - break; - } - } - - @Override - public void afterTextChanged(final Editable editable) { - } - - @Override - public void beforeTextChanged(final CharSequence s, final int start, final int count, - final int after) { - if (mHost.shouldHideAttachmentsWhenSimSelectorShown()) { - hideSimSelector(); - } - } - - private void hideSimSelector() { - if (mInputManager.showHideSimSelector(false /* show */, true /* animate */)) { - // Now that the sim selector has been hidden, reshow the attachments if they - // have been hidden. - hideAttachmentsWhenShowingSims(false /*simPickerVisible*/); - } - } - - @Override - public void onTextChanged(final CharSequence s, final int start, final int before, - final int count) { - final BugleActionBarActivity activity = (mOriginalContext instanceof BugleActionBarActivity) - ? (BugleActionBarActivity) mOriginalContext : null; - if (activity != null && activity.getIsDestroyed()) { - LogUtil.v(LogUtil.BUGLE_TAG, "got onTextChanged after onDestroy"); - - // if we get onTextChanged after the activity is destroyed then, ah, wtf - // b/18176615 - // This appears to have occurred as the result of orientation change. - return; - } - - mBinding.ensureBound(); - updateVisualsOnDraftChanged(); - } - - @Override - public PlainTextEditText getComposeEditText() { - return mComposeEditText; - } - - public void displayPhoto(final Uri photoUri, final Rect imageBounds) { - mHost.displayPhoto(photoUri, imageBounds, true /* isDraft */); - } - - public void updateConversationSelfIdOnExternalChange(final String selfId) { - updateConversationSelfId(selfId, true /* notify */); - } - - /** - * The selfId of the conversation. As soon as the DraftMessageData successfully loads (i.e. - * getSelfId() is non-null), the selfId in DraftMessageData is treated as the sole source - * of truth for conversation self id since it reflects any pending self id change the user - * makes in the UI. - */ - public String getConversationSelfId() { - return mBinding.getData().getSelfId(); - } - - public void selectSim(SubscriptionListEntry subscriptionData) { - final String oldSelfId = getConversationSelfId(); - final String newSelfId = subscriptionData.selfParticipantId; - Assert.notNull(newSelfId); - // Don't attempt to change self if self hasn't been loaded, or if self hasn't changed. - if (oldSelfId == null || TextUtils.equals(oldSelfId, newSelfId)) { - return; - } - updateConversationSelfId(newSelfId, true /* notify */); - } - - public void hideAllComposeInputs(final boolean animate) { - mInputManager.hideAllInputs(animate); - } - - public void saveInputState(final Bundle outState) { - mInputManager.onSaveInputState(outState); - } - - public void resetMediaPickerState() { - mInputManager.resetMediaPickerState(); - } - - public boolean onBackPressed() { - return mInputManager.onBackPressed(); - } - - public boolean onNavigationUpPressed() { - return mInputManager.onNavigationUpPressed(); - } - - public boolean updateActionBar(final ActionBar actionBar) { - return mInputManager != null ? mInputManager.updateActionBar(actionBar) : false; - } - - public static boolean shouldShowSimSelector(final ConversationData convData) { - return convData.getSelfParticipantsCountExcludingDefault(true /* activeOnly */) > 1; - } - - public void sendMessageIgnoreMessageSizeLimit() { - sendMessageInternal(false /* checkMessageSize */); - } - - public void onAttachmentPreviewLongClicked() { - mHost.showAttachmentChooser(); - } - - @Override - public void onDraftAttachmentLoadFailed() { - mHost.notifyOfAttachmentLoadFailed(); - } - - private boolean isOverriddenAvatarAGroup() { - final Uri overridenSelfUri = mHost.getSelfSendButtonIconUri(); - if (overridenSelfUri == null) { - return false; - } - return AvatarUriUtil.TYPE_GROUP_URI.equals(AvatarUriUtil.getAvatarType(overridenSelfUri)); - } - - @Override - public void setAccessibility(boolean enabled) { - if (enabled) { - mAttachMediaButton.setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_YES); - mComposeEditText.setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_YES); - mSendButton.setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_YES); - setSendButtonAccessibility(mSendWidgetMode); - } else { - mSelfSendIcon.setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_NO); - mComposeEditText.setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_NO); - mSendButton.setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_NO); - mAttachMediaButton.setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_NO); - } - } -} diff --git a/src/com/android/messaging/ui/conversation/ConversationActivity.java b/src/com/android/messaging/ui/conversation/ConversationActivity.java deleted file mode 100644 index 8c351c77..00000000 --- a/src/com/android/messaging/ui/conversation/ConversationActivity.java +++ /dev/null @@ -1,381 +0,0 @@ -/* - * Copyright (C) 2015 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.messaging.ui.conversation; - -import android.content.Intent; -import android.graphics.Rect; -import android.net.Uri; -import android.os.Bundle; -import android.text.TextUtils; -import android.view.MenuItem; - -import com.android.messaging.R; -import com.android.messaging.datamodel.MessagingContentProvider; -import com.android.messaging.datamodel.data.MessageData; -import com.android.messaging.ui.BugleActionBarActivity; -import com.android.messaging.ui.UIIntents; -import com.android.messaging.ui.contact.ContactPickerFragment; -import com.android.messaging.ui.contact.ContactPickerFragment.ContactPickerFragmentHost; -import com.android.messaging.ui.conversation.ConversationActivityUiState.ConversationActivityUiStateHost; -import com.android.messaging.ui.conversation.ConversationFragment.ConversationFragmentHost; -import com.android.messaging.ui.conversationlist.ConversationListActivity; -import com.android.messaging.util.Assert; -import com.android.messaging.util.ContentType; -import com.android.messaging.util.LogUtil; -import com.android.messaging.util.UiUtils; - -import androidx.appcompat.app.ActionBar; -import androidx.fragment.app.FragmentManager; -import androidx.fragment.app.FragmentTransaction; - -public class ConversationActivity extends BugleActionBarActivity - implements ContactPickerFragmentHost, ConversationFragmentHost, - ConversationActivityUiStateHost { - public static final int FINISH_RESULT_CODE = 1; - private static final String SAVED_INSTANCE_STATE_UI_STATE_KEY = "uistate"; - - private ConversationActivityUiState mUiState; - - // Fragment transactions cannot be performed after onSaveInstanceState() has been called since - // it will cause state loss. We don't want to call commitAllowingStateLoss() since it's - // dangerous. Therefore, we note when instance state is saved and avoid performing UI state - // updates concerning fragments past that point. - private boolean mInstanceStateSaved; - - // Tracks whether onPause is called. - private boolean mIsPaused; - - @Override - protected void onCreate(final Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - - setContentView(R.layout.conversation_activity); - - final Intent intent = getIntent(); - - // Do our best to restore UI state from saved instance state. - if (savedInstanceState != null) { - mUiState = savedInstanceState.getParcelable(SAVED_INSTANCE_STATE_UI_STATE_KEY); - } else { - if (intent. - getBooleanExtra(UIIntents.UI_INTENT_EXTRA_GOTO_CONVERSATION_LIST, false)) { - // See the comment in BugleWidgetService.getViewMoreConversationsView() why this - // is unfortunately necessary. The Bugle desktop widget can display a list of - // conversations. When there are more conversations that can be displayed in - // the widget, the last item is a "More conversations" item. The way widgets - // are built, the list items can only go to a single fill-in intent which points - // to this ConversationActivity. When the user taps on "More conversations", we - // really want to go to the ConversationList. This code makes that possible. - finish(); - final Intent convListIntent = new Intent(this, ConversationListActivity.class); - convListIntent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP); - startActivity(convListIntent); - return; - } - } - - // If saved instance state doesn't offer a clue, get the info from the intent. - if (mUiState == null) { - final String conversationId = intent.getStringExtra( - UIIntents.UI_INTENT_EXTRA_CONVERSATION_ID); - mUiState = new ConversationActivityUiState(conversationId); - } - mUiState.setHost(this); - mInstanceStateSaved = false; - - // Don't animate UI state change for initial setup. - updateUiState(false /* animate */); - - // See if we're getting called from a widget to directly display an image or video - final String extraToDisplay = - intent.getStringExtra(UIIntents.UI_INTENT_EXTRA_ATTACHMENT_URI); - if (!TextUtils.isEmpty(extraToDisplay)) { - final String contentType = - intent.getStringExtra(UIIntents.UI_INTENT_EXTRA_ATTACHMENT_TYPE); - final Rect bounds = UiUtils.getMeasuredBoundsOnScreen( - findViewById(R.id.conversation_and_compose_container)); - if (ContentType.isImageType(contentType)) { - final Uri imagesUri = MessagingContentProvider.buildConversationImagesUri( - mUiState.getConversationId()); - UIIntents.get().launchFullScreenPhotoViewer( - this, Uri.parse(extraToDisplay), bounds, imagesUri); - } else if (ContentType.isVideoType(contentType)) { - UIIntents.get().launchFullScreenVideoViewer(this, Uri.parse(extraToDisplay)); - } - } - } - - @Override - protected void onSaveInstanceState(final Bundle outState) { - super.onSaveInstanceState(outState); - // After onSaveInstanceState() is called, future changes to mUiState won't update the UI - // anymore, because fragment transactions are not allowed past this point. - // For an activity recreation due to orientation change, the saved instance state keeps - // using the in-memory copy of the UI state instead of writing it to parcel as an - // optimization, so the UI state values may still change in response to, for example, - // focus change from the framework, making mUiState and actual UI inconsistent. - // Therefore, save an exact "snapshot" (clone) of the UI state object to make sure the - // restored UI state ALWAYS matches the actual restored UI components. - outState.putParcelable(SAVED_INSTANCE_STATE_UI_STATE_KEY, mUiState.clone()); - mInstanceStateSaved = true; - } - - @Override - protected void onResume() { - super.onResume(); - - // we need to reset the mInstanceStateSaved flag since we may have just been restored from - // a previous onStop() instead of an onDestroy(). - mInstanceStateSaved = false; - mIsPaused = false; - } - - @Override - protected void onPause() { - super.onPause(); - mIsPaused = true; - } - - @Override - public void onWindowFocusChanged(final boolean hasFocus) { - super.onWindowFocusChanged(hasFocus); - final ConversationFragment conversationFragment = getConversationFragment(); - // When the screen is turned on, the last used activity gets resumed, but it gets - // window focus only after the lock screen is unlocked. - if (hasFocus && conversationFragment != null) { - conversationFragment.setConversationFocus(); - } - } - - @Override - public void onDisplayHeightChanged(final int heightSpecification) { - super.onDisplayHeightChanged(heightSpecification); - invalidateActionBar(); - } - - @Override - protected void onDestroy() { - super.onDestroy(); - if (mUiState != null) { - mUiState.setHost(null); - } - } - - @Override - public void updateActionBar(final ActionBar actionBar) { - super.updateActionBar(actionBar); - final ConversationFragment conversation = getConversationFragment(); - final ContactPickerFragment contactPicker = getContactPicker(); - if (contactPicker != null && mUiState.shouldShowContactPickerFragment()) { - contactPicker.updateActionBar(actionBar); - } else if (conversation != null && mUiState.shouldShowConversationFragment()) { - conversation.updateActionBar(actionBar); - } - - if (isLaunchedFromBubble()) { - actionBar.setHomeButtonEnabled(false); - actionBar.setDisplayHomeAsUpEnabled(false); - } - } - - @Override - public boolean onOptionsItemSelected(final MenuItem menuItem) { - if (super.onOptionsItemSelected(menuItem)) { - return true; - } - if (menuItem.getItemId() == android.R.id.home) { - onNavigationUpPressed(); - return true; - } - return false; - } - - public void onNavigationUpPressed() { - // Let the conversation fragment handle the navigation up press. - final ConversationFragment conversationFragment = getConversationFragment(); - if (conversationFragment != null && conversationFragment.onNavigationUpPressed()) { - return; - } - onFinishCurrentConversation(); - } - - @Override - public void onBackPressed() { - // If action mode is active dismiss it - if (getActionMode() != null) { - dismissActionMode(); - return; - } - - // Let the conversation fragment handle the back press. - final ConversationFragment conversationFragment = getConversationFragment(); - if (conversationFragment != null && conversationFragment.onBackPressed()) { - return; - } - super.onBackPressed(); - } - - private ContactPickerFragment getContactPicker() { - return (ContactPickerFragment) getSupportFragmentManager().findFragmentByTag( - ContactPickerFragment.FRAGMENT_TAG); - } - - private ConversationFragment getConversationFragment() { - return (ConversationFragment) getSupportFragmentManager().findFragmentByTag( - ConversationFragment.FRAGMENT_TAG); - } - - @Override // From ContactPickerFragmentHost - public void onGetOrCreateNewConversation(final String conversationId) { - Assert.isTrue(conversationId != null); - mUiState.onGetOrCreateConversation(conversationId); - } - - @Override // From ContactPickerFragmentHost - public void onBackButtonPressed() { - onBackPressed(); - } - - @Override // From ContactPickerFragmentHost - public void onInitiateAddMoreParticipants() { - mUiState.onAddMoreParticipants(); - } - - - @Override - public void onParticipantCountChanged(final boolean canAddMoreParticipants) { - mUiState.onParticipantCountUpdated(canAddMoreParticipants); - } - - @Override // From ConversationFragmentHost - public void onStartComposeMessage() { - mUiState.onStartMessageCompose(); - } - - @Override // From ConversationFragmentHost - public void onConversationMetadataUpdated() { - invalidateActionBar(); - } - - @Override // From ConversationFragmentHost - public void onConversationMessagesUpdated(final int numberOfMessages) { - } - - @Override // From ConversationFragmentHost - public void onConversationParticipantDataLoaded(final int numberOfParticipants) { - } - - @Override // From ConversationFragmentHost - public boolean isActiveAndFocused() { - return !mIsPaused && hasWindowFocus(); - } - - @Override // From ConversationActivityUiStateListener - public void onConversationContactPickerUiStateChanged(final int oldState, final int newState, - final boolean animate) { - Assert.isTrue(oldState != newState); - updateUiState(animate); - } - - private void updateUiState(final boolean animate) { - if (mInstanceStateSaved || mIsPaused) { - return; - } - Assert.notNull(mUiState); - final Intent intent = getIntent(); - final String conversationId = mUiState.getConversationId(); - - final FragmentManager fragmentManager = getSupportFragmentManager(); - final FragmentTransaction fragmentTransaction = fragmentManager.beginTransaction(); - - final boolean needConversationFragment = mUiState.shouldShowConversationFragment(); - final boolean needContactPickerFragment = mUiState.shouldShowContactPickerFragment(); - ConversationFragment conversationFragment = getConversationFragment(); - - // Set up the conversation fragment. - if (needConversationFragment) { - Assert.notNull(conversationId); - if (conversationFragment == null) { - conversationFragment = new ConversationFragment(); - fragmentTransaction.add(R.id.conversation_fragment_container, - conversationFragment, ConversationFragment.FRAGMENT_TAG); - } - final MessageData draftData = intent.getParcelableExtra( - UIIntents.UI_INTENT_EXTRA_DRAFT_DATA); - if (!needContactPickerFragment) { - // Once the user has committed the audience,remove the draft data from the - // intent to prevent reuse - intent.removeExtra(UIIntents.UI_INTENT_EXTRA_DRAFT_DATA); - } - conversationFragment.setHost(this); - conversationFragment.setConversationInfo(this, conversationId, draftData); - } else if (conversationFragment != null) { - // Don't save draft to DB when removing conversation fragment and switching to - // contact picking mode. The draft is intended for the new group. - conversationFragment.suppressWriteDraft(); - fragmentTransaction.remove(conversationFragment); - } - - // Set up the contact picker fragment. - ContactPickerFragment contactPickerFragment = getContactPicker(); - if (needContactPickerFragment) { - if (contactPickerFragment == null) { - contactPickerFragment = new ContactPickerFragment(); - fragmentTransaction.add(R.id.contact_picker_fragment_container, - contactPickerFragment, ContactPickerFragment.FRAGMENT_TAG); - } - contactPickerFragment.setHost(this); - contactPickerFragment.setContactPickingMode(mUiState.getDesiredContactPickingMode(), - animate); - } else if (contactPickerFragment != null) { - fragmentTransaction.remove(contactPickerFragment); - } - - fragmentTransaction.commit(); - invalidateActionBar(); - } - - @Override - public void onFinishCurrentConversation() { - // Simply finish the current activity. The current design is to leave any empty - // conversations as is. - finishAfterTransition(); - } - - @Override - public boolean shouldResumeComposeMessage() { - return mUiState.shouldResumeComposeMessage(); - } - - @SuppressWarnings("MissingSuperCall") // TODO: fix me - @Override - protected void onActivityResult(final int requestCode, final int resultCode, - final Intent data) { - if (requestCode == ConversationFragment.REQUEST_CHOOSE_ATTACHMENTS && - resultCode == RESULT_OK) { - final ConversationFragment conversationFragment = getConversationFragment(); - if (conversationFragment != null) { - conversationFragment.onAttachmentChoosen(); - } else { - LogUtil.e(LogUtil.BUGLE_TAG, "ConversationFragment is missing after launching " + - "AttachmentChooserActivity!"); - } - } else if (resultCode == FINISH_RESULT_CODE) { - finish(); - } - } -} diff --git a/src/com/android/messaging/ui/conversation/v2/ConversationActivity.kt b/src/com/android/messaging/ui/conversation/ConversationActivity.kt similarity index 87% rename from src/com/android/messaging/ui/conversation/v2/ConversationActivity.kt rename to src/com/android/messaging/ui/conversation/ConversationActivity.kt index 25d9fb70..6db97658 100644 --- a/src/com/android/messaging/ui/conversation/v2/ConversationActivity.kt +++ b/src/com/android/messaging/ui/conversation/ConversationActivity.kt @@ -1,4 +1,4 @@ -package com.android.messaging.ui.conversation.v2 +package com.android.messaging.ui.conversation import android.content.Intent import android.os.Bundle @@ -11,8 +11,8 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import com.android.messaging.datamodel.data.MessageData import com.android.messaging.ui.UIIntents -import com.android.messaging.ui.conversation.v2.entry.model.ConversationEntryLaunchRequest -import com.android.messaging.ui.conversation.v2.navigation.ConversationNavGraph +import com.android.messaging.ui.conversation.entry.model.ConversationEntryLaunchRequest +import com.android.messaging.ui.conversation.navigation.ConversationNavGraph import com.android.messaging.ui.conversationlist.ConversationListActivity import com.android.messaging.ui.core.AppTheme import dagger.hilt.android.AndroidEntryPoint @@ -58,6 +58,15 @@ internal class ConversationActivity : ComponentActivity() { outState.putInt(LAUNCH_GENERATION_STATE_KEY, launchGeneration) } + @Deprecated("Deprecated in Java") + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + super.onActivityResult(requestCode, resultCode, data) + + if (resultCode == FINISH_RESULT_CODE) { + finish() + } + } + private fun applyIntent( intent: Intent, launchGeneration: Int, @@ -117,7 +126,9 @@ internal class ConversationActivity : ComponentActivity() { ) } - private companion object { + companion object { + const val FINISH_RESULT_CODE: Int = 1 + private const val LAUNCH_GENERATION_STATE_KEY = "launch_generation" } } diff --git a/src/com/android/messaging/ui/conversation/ConversationActivityUiState.java b/src/com/android/messaging/ui/conversation/ConversationActivityUiState.java deleted file mode 100644 index 1469c939..00000000 --- a/src/com/android/messaging/ui/conversation/ConversationActivityUiState.java +++ /dev/null @@ -1,306 +0,0 @@ -/* - * Copyright (C) 2015 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.android.messaging.ui.conversation; - -import android.os.Parcel; -import android.os.Parcelable; - -import com.android.messaging.ui.contact.ContactPickerFragment; -import com.android.messaging.util.Assert; -import com.google.common.annotations.VisibleForTesting; - -/** - * Keeps track of the different UI states that the ConversationActivity may be in. This acts as - * a state machine which, based on different actions (e.g. onAddMoreParticipants), notifies the - * ConversationActivity about any state UI change so it can update the visuals. This class - * implements Parcelable and it's persisted across activity tear down and relaunch. - */ -public class ConversationActivityUiState implements Parcelable, Cloneable { - interface ConversationActivityUiStateHost { - void onConversationContactPickerUiStateChanged(int oldState, int newState, boolean animate); - } - - /*------ Overall UI states (conversation & contact picker) ------*/ - - /** Only a full screen conversation is showing. */ - public static final int STATE_CONVERSATION_ONLY = 1; - /** Only a full screen contact picker is showing asking user to pick the initial contact. */ - public static final int STATE_CONTACT_PICKER_ONLY_INITIAL_CONTACT = 2; - /** - * Only a full screen contact picker is showing asking user to pick more participants. This - * happens after the user picked the initial contact, and then decide to go back and add more. - */ - public static final int STATE_CONTACT_PICKER_ONLY_ADD_MORE_CONTACTS = 3; - /** - * Only a full screen contact picker is showing asking user to pick more participants. However - * user has reached max number of conversation participants and can add no more. - */ - public static final int STATE_CONTACT_PICKER_ONLY_MAX_PARTICIPANTS = 4; - /** - * A hybrid mode where the conversation view + contact chips view are showing. This happens - * right after the user picked the initial contact for which a 1-1 conversation is fetched or - * created. - */ - public static final int STATE_HYBRID_WITH_CONVERSATION_AND_CHIPS_VIEW = 5; - - // The overall UI state of the ConversationActivity. - private int mConversationContactUiState; - - // The currently displayed conversation (if any). - private String mConversationId; - - // Indicates whether we should put focus in the compose message view when the - // ConversationFragment is attached. This is a transient state that's not persisted as - // part of the parcelable. - private boolean mPendingResumeComposeMessage = false; - - // The owner ConversationActivity. This is not parceled since the instance always change upon - // object reuse. - private ConversationActivityUiStateHost mHost; - - // Indicates the owning ConverastionActivity is in the process of updating its UI presentation - // to be in sync with the UI states. Outside of the UI updates, the UI states here should - // ALWAYS be consistent with the actual states of the activity. - private int mUiUpdateCount; - - /** - * Create a new instance with an initial conversation id. - */ - ConversationActivityUiState(final String conversationId) { - // The conversation activity may be initialized with only one of two states: - // Conversation-only (when there's a conversation id) or picking initial contact - // (when no conversation id is given). - mConversationId = conversationId; - mConversationContactUiState = conversationId == null ? - STATE_CONTACT_PICKER_ONLY_INITIAL_CONTACT : STATE_CONVERSATION_ONLY; - } - - public void setHost(final ConversationActivityUiStateHost host) { - mHost = host; - } - - public boolean shouldShowConversationFragment() { - return mConversationContactUiState == STATE_HYBRID_WITH_CONVERSATION_AND_CHIPS_VIEW || - mConversationContactUiState == STATE_CONVERSATION_ONLY; - } - - public boolean shouldShowContactPickerFragment() { - return mConversationContactUiState == STATE_CONTACT_PICKER_ONLY_ADD_MORE_CONTACTS || - mConversationContactUiState == STATE_CONTACT_PICKER_ONLY_MAX_PARTICIPANTS || - mConversationContactUiState == STATE_CONTACT_PICKER_ONLY_INITIAL_CONTACT || - mConversationContactUiState == STATE_HYBRID_WITH_CONVERSATION_AND_CHIPS_VIEW; - } - - /** - * Returns whether there's a pending request to resume message compose (i.e. set focus to - * the compose message view and show the soft keyboard). If so, this request will be served - * when the conversation fragment get created and resumed. This happens when the user commits - * participant selection for a group conversation and goes back to the conversation fragment. - * Since conversation fragment creation happens asynchronously, we issue and track this - * pending request for it to be eventually fulfilled. - */ - public boolean shouldResumeComposeMessage() { - if (mPendingResumeComposeMessage) { - // This is a one-shot operation that just keeps track of the pending resume compose - // state. This is also a non-critical operation so we don't care about failure case. - mPendingResumeComposeMessage = false; - return true; - } - return false; - } - - public int getDesiredContactPickingMode() { - switch (mConversationContactUiState) { - case STATE_CONTACT_PICKER_ONLY_ADD_MORE_CONTACTS: - return ContactPickerFragment.MODE_PICK_MORE_CONTACTS; - case STATE_CONTACT_PICKER_ONLY_MAX_PARTICIPANTS: - return ContactPickerFragment.MODE_PICK_MAX_PARTICIPANTS; - case STATE_CONTACT_PICKER_ONLY_INITIAL_CONTACT: - return ContactPickerFragment.MODE_PICK_INITIAL_CONTACT; - case STATE_HYBRID_WITH_CONVERSATION_AND_CHIPS_VIEW: - return ContactPickerFragment.MODE_CHIPS_ONLY; - default: - Assert.fail("Invalid contact picking mode for ConversationActivity!"); - return ContactPickerFragment.MODE_UNDEFINED; - } - } - - public String getConversationId() { - return mConversationId; - } - - /** - * Called whenever the contact picker fragment successfully fetched or created a conversation. - */ - public void onGetOrCreateConversation(final String conversationId) { - int newState = STATE_CONVERSATION_ONLY; - if (mConversationContactUiState == STATE_CONTACT_PICKER_ONLY_INITIAL_CONTACT) { - newState = STATE_HYBRID_WITH_CONVERSATION_AND_CHIPS_VIEW; - } else if (mConversationContactUiState == STATE_CONTACT_PICKER_ONLY_ADD_MORE_CONTACTS || - mConversationContactUiState == STATE_CONTACT_PICKER_ONLY_MAX_PARTICIPANTS) { - newState = STATE_CONVERSATION_ONLY; - } else { - // New conversation should only be created when we are in one of the contact picking - // modes. - Assert.fail("Invalid conversation activity state: can't create conversation!"); - } - mConversationId = conversationId; - performUiStateUpdate(newState, true); - } - - /** - * Called when the user started composing message. If we are in the hybrid chips state, we - * should commit to enter the conversation only state. - */ - public void onStartMessageCompose() { - // This cannot happen when we are in one of the full-screen contact picking states. - Assert.isTrue(mConversationContactUiState != STATE_CONTACT_PICKER_ONLY_INITIAL_CONTACT && - mConversationContactUiState != STATE_CONTACT_PICKER_ONLY_ADD_MORE_CONTACTS && - mConversationContactUiState != STATE_CONTACT_PICKER_ONLY_MAX_PARTICIPANTS); - if (mConversationContactUiState == STATE_HYBRID_WITH_CONVERSATION_AND_CHIPS_VIEW) { - performUiStateUpdate(STATE_CONVERSATION_ONLY, true); - } - } - - /** - * Called when the user initiated an action to add more participants in the hybrid state, - * namely clicking on the "add more participants" button or entered a new contact chip via - * auto-complete. - */ - public void onAddMoreParticipants() { - if (mConversationContactUiState == STATE_HYBRID_WITH_CONVERSATION_AND_CHIPS_VIEW) { - mPendingResumeComposeMessage = true; - performUiStateUpdate(STATE_CONTACT_PICKER_ONLY_ADD_MORE_CONTACTS, true); - } else { - // This is only possible in the hybrid state. - Assert.fail("Invalid conversation activity state: can't add more participants!"); - } - } - - /** - * Called each time the number of participants is updated to check against the limit and - * update the ui state accordingly. - */ - public void onParticipantCountUpdated(final boolean canAddMoreParticipants) { - if (mConversationContactUiState == STATE_CONTACT_PICKER_ONLY_ADD_MORE_CONTACTS - && !canAddMoreParticipants) { - performUiStateUpdate(STATE_CONTACT_PICKER_ONLY_MAX_PARTICIPANTS, false); - } else if (mConversationContactUiState == STATE_CONTACT_PICKER_ONLY_MAX_PARTICIPANTS - && canAddMoreParticipants) { - performUiStateUpdate(STATE_CONTACT_PICKER_ONLY_ADD_MORE_CONTACTS, false); - } - } - - private void performUiStateUpdate(final int conversationContactState, final boolean animate) { - // This starts one UI update cycle, during which we allow the conversation activity's - // UI presentation to be temporarily out of sync with the states here. - beginUiUpdate(); - - if (conversationContactState != mConversationContactUiState) { - final int oldState = mConversationContactUiState; - mConversationContactUiState = conversationContactState; - notifyOnOverallUiStateChanged(oldState, mConversationContactUiState, animate); - } - endUiUpdate(); - } - - private void notifyOnOverallUiStateChanged( - final int oldState, final int newState, final boolean animate) { - // Always verify state validity whenever we have a state change. - assertValidState(); - Assert.isTrue(isUiUpdateInProgress()); - - // Only do this if we are still attached to the host. mHost can be null if the host - // activity is already destroyed, but due to timing the contained UI components may still - // receive events such as focus change and trigger a callback to the Ui state. We'd like - // to guard against those cases. - if (mHost != null) { - mHost.onConversationContactPickerUiStateChanged(oldState, newState, animate); - } - } - - private void assertValidState() { - // Conversation id may be null IF AND ONLY IF the user is picking the initial contact to - // start a conversation. - Assert.isTrue((mConversationContactUiState == STATE_CONTACT_PICKER_ONLY_INITIAL_CONTACT) == - (mConversationId == null)); - } - - private void beginUiUpdate() { - mUiUpdateCount++; - } - - private void endUiUpdate() { - if (--mUiUpdateCount < 0) { - Assert.fail("Unbalanced Ui updates!"); - } - } - - private boolean isUiUpdateInProgress() { - return mUiUpdateCount > 0; - } - - @Override - public int describeContents() { - return 0; - } - - @Override - public void writeToParcel(final Parcel dest, final int flags) { - dest.writeInt(mConversationContactUiState); - dest.writeString(mConversationId); - } - - private ConversationActivityUiState(final Parcel in) { - mConversationContactUiState = in.readInt(); - mConversationId = in.readString(); - - // Always verify state validity whenever we initialize states. - assertValidState(); - } - - public static final Parcelable.Creator CREATOR - = new Parcelable.Creator() { - @Override - public ConversationActivityUiState createFromParcel(final Parcel in) { - return new ConversationActivityUiState(in); - } - - @Override - public ConversationActivityUiState[] newArray(final int size) { - return new ConversationActivityUiState[size]; - } - }; - - @Override - protected ConversationActivityUiState clone() { - try { - return (ConversationActivityUiState) super.clone(); - } catch (CloneNotSupportedException e) { - Assert.fail("ConversationActivityUiState: failed to clone(). Is there a mutable " + - "reference?"); - } - return null; - } - - /** - * allows for overridding the internal UI state. Should never be called except by test code. - */ - @VisibleForTesting - void testSetUiState(final int uiState) { - mConversationContactUiState = uiState; - } -} diff --git a/src/com/android/messaging/ui/conversation/ConversationFastScroller.java b/src/com/android/messaging/ui/conversation/ConversationFastScroller.java deleted file mode 100644 index b836172b..00000000 --- a/src/com/android/messaging/ui/conversation/ConversationFastScroller.java +++ /dev/null @@ -1,479 +0,0 @@ -/* - * Copyright (C) 2015 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.messaging.ui.conversation; - -import android.animation.AnimatorSet; -import android.animation.ObjectAnimator; -import android.content.Context; -import android.content.res.Resources; -import android.graphics.Rect; -import android.graphics.drawable.StateListDrawable; -import android.os.Handler; -import android.util.StateSet; -import android.view.LayoutInflater; -import android.view.MotionEvent; -import android.view.View; -import android.view.View.MeasureSpec; -import android.view.View.OnLayoutChangeListener; -import android.view.ViewGroupOverlay; -import android.widget.ImageView; -import android.widget.TextView; - -import com.android.messaging.R; -import com.android.messaging.datamodel.data.ConversationMessageData; -import com.android.messaging.ui.ConversationDrawables; -import com.android.messaging.util.Dates; - -import androidx.recyclerview.widget.LinearLayoutManager; -import androidx.recyclerview.widget.RecyclerView; -import androidx.recyclerview.widget.RecyclerView.AdapterDataObserver; -import androidx.recyclerview.widget.RecyclerView.ViewHolder; - -/** - * Adds a "fast-scroll" bar to the conversation RecyclerView that shows the current position within - * the conversation and allows quickly moving to another position by dragging the scrollbar thumb - * up or down. As the thumb is dragged, we show a floating bubble alongside it that shows the - * date/time of the first visible message at the current position. - */ -public class ConversationFastScroller extends RecyclerView.OnScrollListener implements - OnLayoutChangeListener, RecyclerView.OnItemTouchListener { - - /** - * Creates a {@link ConversationFastScroller} instance, attached to the provided - * {@link RecyclerView}. - * - * @param rv the conversation RecyclerView - * @param position where the scrollbar should appear (either {@code POSITION_RIGHT_SIDE} or - * {@code POSITION_LEFT_SIDE}) - * @return a new ConversationFastScroller, or {@code null} if fast-scrolling is not supported - * (the feature requires Jellybean MR2 or newer) - */ - public static ConversationFastScroller addTo(RecyclerView rv, int position) { - return new ConversationFastScroller(rv, position); - } - - public static final int POSITION_RIGHT_SIDE = 0; - public static final int POSITION_LEFT_SIDE = 1; - - private static final int MIN_PAGES_TO_ENABLE = 7; - private static final int SHOW_ANIMATION_DURATION_MS = 150; - private static final int HIDE_ANIMATION_DURATION_MS = 300; - private static final int HIDE_DELAY_MS = 1500; - - private final Context mContext; - private final RecyclerView mRv; - private final ViewGroupOverlay mOverlay; - private final ImageView mTrackImageView; - private final ImageView mThumbImageView; - private final TextView mPreviewTextView; - - private final int mTrackWidth; - private final int mThumbHeight; - private final int mPreviewHeight; - private final int mPreviewMinWidth; - private final int mPreviewMarginTop; - private final int mPreviewMarginLeftRight; - private final int mTouchSlop; - - private final Rect mContainer = new Rect(); - private final Handler mHandler = new Handler(); - - // Whether to render the scrollbar on the right side (otherwise it'll be on the left). - private final boolean mPosRight; - - // Whether the scrollbar is currently visible (it may still be animating). - private boolean mVisible = false; - - // Whether we are waiting to hide the scrollbar (i.e. scrolling has stopped). - private boolean mPendingHide = false; - - // Whether the user is currently dragging the thumb up or down. - private boolean mDragging = false; - - // Animations responsible for hiding the scrollbar & preview. May be null. - private AnimatorSet mHideAnimation; - private ObjectAnimator mHidePreviewAnimation; - - private final Runnable mHideTrackRunnable = new Runnable() { - @Override - public void run() { - hide(true /* animate */); - mPendingHide = false; - } - }; - - private ConversationFastScroller(RecyclerView rv, int position) { - mContext = rv.getContext(); - mRv = rv; - mRv.addOnLayoutChangeListener(this); - mRv.addOnScrollListener(this); - mRv.addOnItemTouchListener(this); - mRv.getAdapter().registerAdapterDataObserver(new AdapterDataObserver() { - @Override - public void onChanged() { - updateScrollPos(); - } - }); - mPosRight = (position == POSITION_RIGHT_SIDE); - - // Cache the dimensions we'll need during layout - final Resources res = mContext.getResources(); - mTrackWidth = res.getDimensionPixelSize(R.dimen.fastscroll_track_width); - mThumbHeight = res.getDimensionPixelSize(R.dimen.fastscroll_thumb_height); - mPreviewHeight = res.getDimensionPixelSize(R.dimen.fastscroll_preview_height); - mPreviewMinWidth = res.getDimensionPixelSize(R.dimen.fastscroll_preview_min_width); - mPreviewMarginTop = res.getDimensionPixelOffset(R.dimen.fastscroll_preview_margin_top); - mPreviewMarginLeftRight = res.getDimensionPixelOffset( - R.dimen.fastscroll_preview_margin_left_right); - mTouchSlop = res.getDimensionPixelOffset(R.dimen.fastscroll_touch_slop); - - final LayoutInflater inflator = LayoutInflater.from(mContext); - mTrackImageView = (ImageView) inflator.inflate(R.layout.fastscroll_track, null); - mThumbImageView = (ImageView) inflator.inflate(R.layout.fastscroll_thumb, null); - mPreviewTextView = (TextView) inflator.inflate(R.layout.fastscroll_preview, null); - - refreshConversationThemeColor(); - - // Add the fast scroll views to the overlay, so they are rendered above the list - mOverlay = rv.getOverlay(); - mOverlay.add(mTrackImageView); - mOverlay.add(mThumbImageView); - mOverlay.add(mPreviewTextView); - - hide(false /* animate */); - mPreviewTextView.setAlpha(0f); - } - - public void refreshConversationThemeColor() { - mPreviewTextView.setBackground( - ConversationDrawables.get().getFastScrollPreviewDrawable(mPosRight)); - - final StateListDrawable drawable = new StateListDrawable(); - drawable.addState(new int[]{ android.R.attr.state_pressed }, - ConversationDrawables.get().getFastScrollThumbDrawable(true /* pressed */)); - drawable.addState(StateSet.WILD_CARD, - ConversationDrawables.get().getFastScrollThumbDrawable(false /* pressed */)); - mThumbImageView.setImageDrawable(drawable); - } - - @Override - public void onScrollStateChanged(final RecyclerView view, final int newState) { - if (newState == RecyclerView.SCROLL_STATE_DRAGGING) { - // Only show the scrollbar once the user starts scrolling - if (!mVisible && isEnabled()) { - show(); - } - cancelAnyPendingHide(); - } else if (newState == RecyclerView.SCROLL_STATE_IDLE && !mDragging) { - // Hide the scrollbar again after scrolling stops - hideAfterDelay(); - } - } - - private boolean isEnabled() { - final int range = mRv.computeVerticalScrollRange(); - final int extent = mRv.computeVerticalScrollExtent(); - - if (range == 0 || extent == 0) { - return false; // Conversation isn't long enough to scroll - } - // Only enable scrollbars for conversations long enough that they would require several - // flings to scroll through. - final float pages = (float) range / extent; - return (pages > MIN_PAGES_TO_ENABLE); - } - - private void show() { - if (mHideAnimation != null && mHideAnimation.isRunning()) { - mHideAnimation.cancel(); - } - // Slide the scrollbar in from the side - ObjectAnimator trackSlide = ObjectAnimator.ofFloat(mTrackImageView, View.TRANSLATION_X, 0); - ObjectAnimator thumbSlide = ObjectAnimator.ofFloat(mThumbImageView, View.TRANSLATION_X, 0); - AnimatorSet animation = new AnimatorSet(); - animation.playTogether(trackSlide, thumbSlide); - animation.setDuration(SHOW_ANIMATION_DURATION_MS); - animation.start(); - - mVisible = true; - updateScrollPos(); - } - - private void hideAfterDelay() { - cancelAnyPendingHide(); - mHandler.postDelayed(mHideTrackRunnable, HIDE_DELAY_MS); - mPendingHide = true; - } - - private void cancelAnyPendingHide() { - if (mPendingHide) { - mHandler.removeCallbacks(mHideTrackRunnable); - } - } - - private void hide(boolean animate) { - final int hiddenTranslationX = mPosRight ? mTrackWidth : -mTrackWidth; - if (animate) { - // Slide the scrollbar off to the side - ObjectAnimator trackSlide = ObjectAnimator.ofFloat(mTrackImageView, View.TRANSLATION_X, - hiddenTranslationX); - ObjectAnimator thumbSlide = ObjectAnimator.ofFloat(mThumbImageView, View.TRANSLATION_X, - hiddenTranslationX); - mHideAnimation = new AnimatorSet(); - mHideAnimation.playTogether(trackSlide, thumbSlide); - mHideAnimation.setDuration(HIDE_ANIMATION_DURATION_MS); - mHideAnimation.start(); - } else { - mTrackImageView.setTranslationX(hiddenTranslationX); - mThumbImageView.setTranslationX(hiddenTranslationX); - } - - mVisible = false; - } - - private void showPreview() { - if (mHidePreviewAnimation != null && mHidePreviewAnimation.isRunning()) { - mHidePreviewAnimation.cancel(); - } - mPreviewTextView.setAlpha(1f); - } - - private void hidePreview() { - mHidePreviewAnimation = ObjectAnimator.ofFloat(mPreviewTextView, View.ALPHA, 0f); - mHidePreviewAnimation.setDuration(HIDE_ANIMATION_DURATION_MS); - mHidePreviewAnimation.start(); - } - - @Override - public void onScrolled(final RecyclerView view, final int dx, final int dy) { - updateScrollPos(); - } - - private void updateScrollPos() { - if (!mVisible) { - return; - } - final int verticalScrollLength = mContainer.height() - mThumbHeight; - final int verticalScrollStart = mContainer.top + mThumbHeight / 2; - - final float scrollRatio = computeScrollRatio(); - final int thumbCenterY = verticalScrollStart + (int)(verticalScrollLength * scrollRatio); - layoutThumb(thumbCenterY); - - if (mDragging) { - updatePreviewText(); - layoutPreview(thumbCenterY); - } - } - - /** - * Returns the current position in the conversation, as a value between 0 and 1, inclusive. - * The top of the conversation is 0, the bottom is 1, the exact middle is 0.5, and so on. - */ - private float computeScrollRatio() { - final int range = mRv.computeVerticalScrollRange(); - final int extent = mRv.computeVerticalScrollExtent(); - int offset = mRv.computeVerticalScrollOffset(); - - if (range == 0 || extent == 0) { - // If the conversation doesn't scroll, we're at the bottom. - return 1.0f; - } - final int scrollRange = range - extent; - offset = Math.min(offset, scrollRange); - return offset / (float) scrollRange; - } - - private void updatePreviewText() { - final LinearLayoutManager lm = (LinearLayoutManager) mRv.getLayoutManager(); - final int pos = lm.findFirstVisibleItemPosition(); - if (pos == RecyclerView.NO_POSITION) { - return; - } - final ViewHolder vh = mRv.findViewHolderForAdapterPosition(pos); - if (vh == null) { - // This can happen if the messages update while we're dragging the thumb. - return; - } - final ConversationMessageView messageView = (ConversationMessageView) vh.itemView; - final ConversationMessageData messageData = messageView.getData(); - final long timestamp = messageData.getReceivedTimeStamp(); - final CharSequence timestampText = Dates.getFastScrollPreviewTimeString(timestamp); - mPreviewTextView.setText(timestampText); - } - - @Override - public boolean onInterceptTouchEvent(RecyclerView rv, MotionEvent e) { - if (!mVisible) { - return false; - } - // If the user presses down on the scroll thumb, we'll start intercepting events from the - // RecyclerView so we can handle the move events while they're dragging it up/down. - final int action = e.getActionMasked(); - switch (action) { - case MotionEvent.ACTION_DOWN: - if (isInsideThumb(e.getX(), e.getY())) { - startDrag(); - return true; - } - break; - case MotionEvent.ACTION_MOVE: - if (mDragging) { - return true; - } - case MotionEvent.ACTION_CANCEL: - case MotionEvent.ACTION_UP: - if (mDragging) { - cancelDrag(); - } - return false; - } - return false; - } - - private boolean isInsideThumb(float x, float y) { - final int hitTargetLeft = mThumbImageView.getLeft() - mTouchSlop; - final int hitTargetRight = mThumbImageView.getRight() + mTouchSlop; - - if (x < hitTargetLeft || x > hitTargetRight) { - return false; - } - if (y < mThumbImageView.getTop() || y > mThumbImageView.getBottom()) { - return false; - } - return true; - } - - @Override - public void onTouchEvent(RecyclerView rv, MotionEvent e) { - if (!mDragging) { - return; - } - final int action = e.getActionMasked(); - switch (action) { - case MotionEvent.ACTION_MOVE: - handleDragMove(e.getY()); - break; - case MotionEvent.ACTION_CANCEL: - case MotionEvent.ACTION_UP: - cancelDrag(); - break; - } - } - - @Override - public void onRequestDisallowInterceptTouchEvent(boolean disallowIntercept) { - } - - private void startDrag() { - mDragging = true; - mThumbImageView.setPressed(true); - updateScrollPos(); - showPreview(); - cancelAnyPendingHide(); - } - - private void handleDragMove(float y) { - final int verticalScrollLength = mContainer.height() - mThumbHeight; - final int verticalScrollStart = mContainer.top + (mThumbHeight / 2); - - // Convert the desired position from px to a scroll position in the conversation. - float dragScrollRatio = (y - verticalScrollStart) / verticalScrollLength; - dragScrollRatio = Math.max(dragScrollRatio, 0.0f); - dragScrollRatio = Math.min(dragScrollRatio, 1.0f); - - // Scroll the RecyclerView to a new position. - final int itemCount = mRv.getAdapter().getItemCount(); - final int itemPos = (int)((itemCount - 1) * dragScrollRatio); - mRv.scrollToPosition(itemPos); - } - - private void cancelDrag() { - mDragging = false; - mThumbImageView.setPressed(false); - hidePreview(); - hideAfterDelay(); - } - - @Override - public void onLayoutChange(View v, int left, int top, int right, int bottom, - int oldLeft, int oldTop, int oldRight, int oldBottom) { - if (!mVisible) { - hide(false /* animate */); - } - // The container is the size of the RecyclerView that's visible on screen. We have to - // exclude the top padding, because it's usually hidden behind the conversation action bar. - mContainer.set(left, top + mRv.getPaddingTop(), right, bottom); - layoutTrack(); - updateScrollPos(); - } - - private void layoutTrack() { - int trackHeight = Math.max(0, mContainer.height()); - int widthMeasureSpec = MeasureSpec.makeMeasureSpec(mTrackWidth, MeasureSpec.EXACTLY); - int heightMeasureSpec = MeasureSpec.makeMeasureSpec(trackHeight, MeasureSpec.EXACTLY); - mTrackImageView.measure(widthMeasureSpec, heightMeasureSpec); - - int left = mPosRight ? (mContainer.right - mTrackWidth) : mContainer.left; - int top = mContainer.top; - int right = mPosRight ? mContainer.right : (mContainer.left + mTrackWidth); - int bottom = mContainer.bottom; - mTrackImageView.layout(left, top, right, bottom); - } - - private void layoutThumb(int centerY) { - int widthMeasureSpec = MeasureSpec.makeMeasureSpec(mTrackWidth, MeasureSpec.EXACTLY); - int heightMeasureSpec = MeasureSpec.makeMeasureSpec(mThumbHeight, MeasureSpec.EXACTLY); - mThumbImageView.measure(widthMeasureSpec, heightMeasureSpec); - - int left = mPosRight ? (mContainer.right - mTrackWidth) : mContainer.left; - int top = centerY - (mThumbImageView.getHeight() / 2); - int right = mPosRight ? mContainer.right : (mContainer.left + mTrackWidth); - int bottom = top + mThumbHeight; - mThumbImageView.layout(left, top, right, bottom); - } - - private void layoutPreview(int centerY) { - int widthMeasureSpec = MeasureSpec.makeMeasureSpec(mContainer.width(), MeasureSpec.AT_MOST); - int heightMeasureSpec = MeasureSpec.makeMeasureSpec(mPreviewHeight, MeasureSpec.EXACTLY); - mPreviewTextView.measure(widthMeasureSpec, heightMeasureSpec); - - // Ensure that the preview bubble is at least as wide as it is tall - if (mPreviewTextView.getMeasuredWidth() < mPreviewMinWidth) { - widthMeasureSpec = MeasureSpec.makeMeasureSpec(mPreviewMinWidth, MeasureSpec.EXACTLY); - mPreviewTextView.measure(widthMeasureSpec, heightMeasureSpec); - } - final int previewMinY = mContainer.top + mPreviewMarginTop; - - final int left, right; - if (mPosRight) { - right = mContainer.right - mTrackWidth - mPreviewMarginLeftRight; - left = right - mPreviewTextView.getMeasuredWidth(); - } else { - left = mContainer.left + mTrackWidth + mPreviewMarginLeftRight; - right = left + mPreviewTextView.getMeasuredWidth(); - } - - int bottom = centerY; - int top = bottom - mPreviewTextView.getMeasuredHeight(); - if (top < previewMinY) { - top = previewMinY; - bottom = top + mPreviewTextView.getMeasuredHeight(); - } - mPreviewTextView.layout(left, top, right, bottom); - } -} diff --git a/src/com/android/messaging/ui/conversation/ConversationFragment.java b/src/com/android/messaging/ui/conversation/ConversationFragment.java deleted file mode 100644 index 5f186dc3..00000000 --- a/src/com/android/messaging/ui/conversation/ConversationFragment.java +++ /dev/null @@ -1,1661 +0,0 @@ -/* - * Copyright (C) 2015 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.messaging.ui.conversation; - -import android.app.Activity; -import android.app.AlertDialog; -import android.app.DownloadManager; -import android.content.BroadcastReceiver; -import android.content.ClipData; -import android.content.ClipboardManager; -import android.content.Context; -import android.content.DialogInterface; -import android.content.DialogInterface.OnClickListener; -import android.content.Intent; -import android.content.IntentFilter; -import android.content.res.Configuration; -import android.database.Cursor; -import android.graphics.Point; -import android.graphics.Rect; -import android.graphics.drawable.ColorDrawable; -import android.net.Uri; -import android.os.Bundle; -import android.os.Environment; -import android.os.Handler; -import android.os.Parcelable; -import android.telephony.PhoneNumberUtils; -import android.text.TextUtils; -import android.view.ActionMode; -import android.view.Display; -import android.view.LayoutInflater; -import android.view.Menu; -import android.view.MenuInflater; -import android.view.MenuItem; -import android.view.View; -import android.view.ViewConfiguration; -import android.view.ViewGroup; -import android.widget.TextView; - -import com.android.messaging.R; -import com.android.messaging.datamodel.DataModel; -import com.android.messaging.datamodel.MessagingContentProvider; -import com.android.messaging.datamodel.action.InsertNewMessageAction; -import com.android.messaging.datamodel.binding.Binding; -import com.android.messaging.datamodel.binding.BindingBase; -import com.android.messaging.datamodel.binding.ImmutableBindingRef; -import com.android.messaging.datamodel.data.ConversationData; -import com.android.messaging.datamodel.data.ConversationData.ConversationDataListener; -import com.android.messaging.datamodel.data.ConversationMessageData; -import com.android.messaging.datamodel.data.ConversationParticipantsData; -import com.android.messaging.datamodel.data.DraftMessageData; -import com.android.messaging.datamodel.data.DraftMessageData.DraftMessageDataListener; -import com.android.messaging.datamodel.data.MessageData; -import com.android.messaging.datamodel.data.MessagePartData; -import com.android.messaging.datamodel.data.ParticipantData; -import com.android.messaging.datamodel.data.SubscriptionListData.SubscriptionListEntry; -import com.android.messaging.ui.AttachmentPreview; -import com.android.messaging.ui.BugleActionBarActivity; -import com.android.messaging.ui.ConversationDrawables; -import com.android.messaging.ui.SnackBar; -import com.android.messaging.ui.UIIntents; -import com.android.messaging.ui.animation.PopupTransitionAnimation; -import com.android.messaging.ui.contact.AddContactsConfirmationDialog; -import com.android.messaging.ui.conversation.ComposeMessageView.IComposeMessageViewHost; -import com.android.messaging.ui.conversation.ConversationInputManager.ConversationInputHost; -import com.android.messaging.ui.conversation.ConversationMessageView.ConversationMessageViewHost; -import com.android.messaging.ui.mediapicker.MediaPicker; -import com.android.messaging.util.AccessibilityUtil; -import com.android.messaging.util.Assert; -import com.android.messaging.util.AvatarUriUtil; -import com.android.messaging.util.ChangeDefaultSmsAppHelper; -import com.android.messaging.util.ContentType; -import com.android.messaging.util.ImeUtil; -import com.android.messaging.util.LogUtil; -import com.android.messaging.util.PhoneUtils; -import com.android.messaging.util.SafeAsyncTask; -import com.android.messaging.util.TextUtil; -import com.android.messaging.util.UiUtils; -import com.android.messaging.util.UriUtil; -import com.google.common.annotations.VisibleForTesting; - -import java.io.File; -import java.util.ArrayList; -import java.util.List; - -import androidx.appcompat.app.ActionBar; -import androidx.core.text.BidiFormatter; -import androidx.core.text.TextDirectionHeuristicsCompat; -import androidx.fragment.app.Fragment; -import androidx.fragment.app.FragmentManager; -import androidx.fragment.app.FragmentTransaction; -import androidx.loader.app.LoaderManager; -import androidx.localbroadcastmanager.content.LocalBroadcastManager; -import androidx.recyclerview.widget.DefaultItemAnimator; -import androidx.recyclerview.widget.LinearLayoutManager; -import androidx.recyclerview.widget.RecyclerView; -import androidx.recyclerview.widget.RecyclerView.ViewHolder; - -/** - * Shows a list of messages/parts comprising a conversation. - */ -public class ConversationFragment extends Fragment implements ConversationDataListener, - IComposeMessageViewHost, ConversationMessageViewHost, ConversationInputHost, - DraftMessageDataListener { - - public interface ConversationFragmentHost extends ImeUtil.ImeStateHost { - void onStartComposeMessage(); - void onConversationMetadataUpdated(); - boolean shouldResumeComposeMessage(); - void onFinishCurrentConversation(); - void invalidateActionBar(); - ActionMode startActionMode(ActionMode.Callback callback); - void dismissActionMode(); - ActionMode getActionMode(); - void onConversationMessagesUpdated(int numberOfMessages); - void onConversationParticipantDataLoaded(int numberOfParticipants); - boolean isActiveAndFocused(); - } - - public static final String FRAGMENT_TAG = "conversation"; - - static final int REQUEST_CHOOSE_ATTACHMENTS = 2; - private static final int JUMP_SCROLL_THRESHOLD = 15; - // We animate the message from draft to message list, if we the message doesn't show up in the - // list within this time limit, then we just do a fade in animation instead - public static final int MESSAGE_ANIMATION_MAX_WAIT = 500; - - private ComposeMessageView mComposeMessageView; - private RecyclerView mRecyclerView; - private ConversationMessageAdapter mAdapter; - private ConversationFastScroller mFastScroller; - - private View mConversationComposeDivider; - private ChangeDefaultSmsAppHelper mChangeDefaultSmsAppHelper; - - private String mConversationId; - // If the fragment receives a draft as part of the invocation this is set - private MessageData mIncomingDraft; - - // This binding keeps track of our associated ConversationData instance - // A binding should have the lifetime of the owning component, - // don't recreate, unbind and bind if you need new data - @VisibleForTesting - final Binding mBinding = BindingBase.createBinding(this); - - // Saved Instance State Data - only for temporal data which is nice to maintain but not - // critical for correctness. - private static final String SAVED_INSTANCE_STATE_LIST_VIEW_STATE_KEY = "conversationViewState"; - private Parcelable mListState; - - private ConversationFragmentHost mHost; - - protected List mFilterResults; - - // The minimum scrolling distance between RecyclerView's scroll change event beyong which - // a fling motion is considered fast, in which case we'll delay load image attachments for - // perf optimization. - private int mFastFlingThreshold; - - // ConversationMessageView that is currently selected - private ConversationMessageView mSelectedMessage; - - // Attachment data for the attachment within the selected message that was long pressed - private MessagePartData mSelectedAttachment; - - // Normally, as soon as draft message is loaded, we trust the UI state held in - // ComposeMessageView to be the only source of truth (incl. the conversation self id). However, - // there can be external events that forces the UI state to change, such as SIM state changes - // or SIM auto-switching on receiving a message. This receiver is used to receive such - // local broadcast messages and reflect the change in the UI. - private final BroadcastReceiver mConversationSelfIdChangeReceiver = new BroadcastReceiver() { - @Override - public void onReceive(final Context context, final Intent intent) { - final String conversationId = - intent.getStringExtra(UIIntents.UI_INTENT_EXTRA_CONVERSATION_ID); - final String selfId = - intent.getStringExtra(UIIntents.UI_INTENT_EXTRA_CONVERSATION_SELF_ID); - Assert.notNull(conversationId); - Assert.notNull(selfId); - if (isBound() && TextUtils - .equals(mBinding.getData().getConversationId(), conversationId)) { - mComposeMessageView.updateConversationSelfIdOnExternalChange(selfId); - } - } - }; - - // Flag to prevent writing draft to DB on pause - private boolean mSuppressWriteDraft; - - // Indicates whether local draft should be cleared due to external draft changes that must - // be reloaded from db - private boolean mClearLocalDraft; - private ImmutableBindingRef mDraftMessageDataModel; - - private boolean isScrolledToBottom() { - if (mRecyclerView.getChildCount() == 0) { - return true; - } - final View lastView = mRecyclerView.getChildAt(mRecyclerView.getChildCount() - 1); - int lastVisibleItem = ((LinearLayoutManager) mRecyclerView - .getLayoutManager()).findLastVisibleItemPosition(); - if (lastVisibleItem < 0) { - // If the recyclerView height is 0, then the last visible item position is -1 - // Try to compute the position of the last item, even though it's not visible - final long id = mRecyclerView.getChildItemId(lastView); - final RecyclerView.ViewHolder holder = mRecyclerView.findViewHolderForItemId(id); - if (holder != null) { - lastVisibleItem = holder.getAdapterPosition(); - } - } - final int totalItemCount = mRecyclerView.getAdapter().getItemCount(); - final boolean isAtBottom = (lastVisibleItem + 1 == totalItemCount); - return isAtBottom && lastView.getBottom() <= mRecyclerView.getHeight(); - } - - private void scrollToBottom(final boolean smoothScroll) { - if (mAdapter.getItemCount() > 0) { - scrollToPosition(mAdapter.getItemCount() - 1, smoothScroll); - } - } - - private int mScrollToDismissThreshold; - private final RecyclerView.OnScrollListener mListScrollListener = - new RecyclerView.OnScrollListener() { - // Keeps track of cumulative scroll delta during a scroll event, which we may use to - // hide the media picker & co. - private int mCumulativeScrollDelta; - private boolean mScrollToDismissHandled; - private boolean mWasScrolledToBottom = true; - private int mScrollState = RecyclerView.SCROLL_STATE_IDLE; - - @Override - public void onScrollStateChanged(final RecyclerView view, final int newState) { - if (newState == RecyclerView.SCROLL_STATE_IDLE) { - // Reset scroll states. - mCumulativeScrollDelta = 0; - mScrollToDismissHandled = false; - } else if (newState == RecyclerView.SCROLL_STATE_DRAGGING) { - mRecyclerView.getItemAnimator().endAnimations(); - } - mScrollState = newState; - } - - @Override - public void onScrolled(final RecyclerView view, final int dx, final int dy) { - if (mScrollState == RecyclerView.SCROLL_STATE_DRAGGING && - !mScrollToDismissHandled) { - mCumulativeScrollDelta += dy; - // Dismiss the keyboard only when the user scroll up (into the past). - if (mCumulativeScrollDelta < -mScrollToDismissThreshold) { - mComposeMessageView.hideAllComposeInputs(false /* animate */); - mScrollToDismissHandled = true; - } - } - if (mWasScrolledToBottom != isScrolledToBottom()) { - mConversationComposeDivider.animate().alpha(isScrolledToBottom() ? 0 : 1); - mWasScrolledToBottom = isScrolledToBottom(); - } - } - }; - - private final ActionMode.Callback mMessageActionModeCallback = new ActionMode.Callback() { - @Override - public boolean onCreateActionMode(final ActionMode actionMode, final Menu menu) { - if (mSelectedMessage == null) { - return false; - } - final ConversationMessageData data = mSelectedMessage.getData(); - final MenuInflater menuInflater = getActivity().getMenuInflater(); - menuInflater.inflate(R.menu.conversation_fragment_select_menu, menu); - menu.findItem(R.id.action_download).setVisible(data.getShowDownloadMessage()); - menu.findItem(R.id.action_send).setVisible(data.getShowResendMessage()); - - // ShareActionProvider does not work with ActionMode. So we use a normal menu item. - menu.findItem(R.id.share_message_menu).setVisible(data.getCanForwardMessage()); - menu.findItem(R.id.save_attachment).setVisible(mSelectedAttachment != null); - menu.findItem(R.id.forward_message_menu).setVisible(data.getCanForwardMessage()); - - // TODO: We may want to support copying attachments in the future, but it's - // unclear which attachment to pick when we make this context menu at the message level - // instead of the part level - menu.findItem(R.id.copy_text).setVisible(data.getCanCopyMessageToClipboard()); - - return true; - } - - @Override - public boolean onPrepareActionMode(final ActionMode actionMode, final Menu menu) { - return true; - } - - @Override - public boolean onActionItemClicked(final ActionMode actionMode, final MenuItem menuItem) { - final ConversationMessageData data = mSelectedMessage.getData(); - final String messageId = data.getMessageId(); - int itemId = menuItem.getItemId(); - if (itemId == R.id.save_attachment) { - final SaveAttachmentTask saveAttachmentTask = new SaveAttachmentTask(getActivity()); - for (final MessagePartData part : data.getAttachments()) { - saveAttachmentTask.addAttachmentToSave(part.getContentUri(), - part.getContentType()); - } - if (saveAttachmentTask.getAttachmentCount() > 0) { - saveAttachmentTask.executeOnThreadPool(); - mHost.dismissActionMode(); - } - return true; - } - if (itemId == R.id.action_delete_message) { - if (mSelectedMessage != null) { - deleteMessage(messageId); - } - return true; - } - if (itemId == R.id.action_download) { - if (mSelectedMessage != null) { - retryDownload(messageId); - mHost.dismissActionMode(); - } - return true; - } - if (itemId == R.id.action_send) { - if (mSelectedMessage != null) { - retrySend(messageId); - mHost.dismissActionMode(); - } - return true; - } - if (itemId == R.id.copy_text) { - Assert.isTrue(data.hasText()); - final ClipboardManager clipboard = (ClipboardManager) getActivity() - .getSystemService(Context.CLIPBOARD_SERVICE); - clipboard.setPrimaryClip( - ClipData.newPlainText(null /* label */, data.getText())); - mHost.dismissActionMode(); - return true; - } - if (itemId == R.id.details_menu) { - MessageDetailsDialog.show( - getActivity(), data, mBinding.getData().getParticipants(), - mBinding.getData().getSelfParticipantById(data.getSelfParticipantId())); - mHost.dismissActionMode(); - return true; - } - if (itemId == R.id.share_message_menu) { - shareMessage(data); - mHost.dismissActionMode(); - return true; - } - if (itemId == R.id.forward_message_menu) {// TODO: Currently we are forwarding one part at a time, instead of - // the entire message. Change this to forwarding the entire message when we - // use message-based cursor in conversation. - final MessageData message = mBinding.getData().createForwardedMessage(data); - UIIntents.get().launchForwardMessageActivity(getActivity(), message); - mHost.dismissActionMode(); - return true; - } - return false; - } - - private void shareMessage(final ConversationMessageData data) { - // Figure out what to share. - MessagePartData attachmentToShare = mSelectedAttachment; - // If the user long-pressed on the background, we will share the text (if any) - // or the first attachment. - if (mSelectedAttachment == null - && TextUtil.isAllWhitespace(data.getText())) { - final List attachments = data.getAttachments(); - if (attachments.size() > 0) { - attachmentToShare = attachments.get(0); - } - } - - final Intent shareIntent = new Intent(); - shareIntent.setAction(Intent.ACTION_SEND); - if (attachmentToShare == null) { - shareIntent.putExtra(Intent.EXTRA_TEXT, data.getText()); - shareIntent.setType("text/plain"); - } else { - shareIntent.putExtra( - Intent.EXTRA_STREAM, attachmentToShare.getContentUri()); - shareIntent.setType(attachmentToShare.getContentType()); - } - final CharSequence title = getResources().getText(R.string.action_share); - startActivity(Intent.createChooser(shareIntent, title)); - } - - @Override - public void onDestroyActionMode(final ActionMode actionMode) { - selectMessage(null); - } - }; - - /** - * {@inheritDoc} from Fragment - */ - @Override - public void onCreate(final Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - mFastFlingThreshold = getResources().getDimensionPixelOffset( - R.dimen.conversation_fast_fling_threshold); - mAdapter = new ConversationMessageAdapter(getActivity(), null, this, - null, - // Sets the item click listener on the Recycler item views. - new View.OnClickListener() { - @Override - public void onClick(final View v) { - final ConversationMessageView messageView = (ConversationMessageView) v; - handleMessageClick(messageView); - } - }, - new View.OnLongClickListener() { - @Override - public boolean onLongClick(final View view) { - selectMessage((ConversationMessageView) view); - return true; - } - } - ); - } - - /** - * setConversationInfo() may be called before or after onCreate(). When a user initiate a - * conversation from compose, the ConversationActivity creates this fragment and calls - * setConversationInfo(), so it happens before onCreate(). However, when the activity is - * restored from saved instance state, the ConversationFragment is created automatically by - * the fragment, before ConversationActivity has a chance to call setConversationInfo(). Since - * the ability to start loading data depends on both methods being called, we need to start - * loading when onActivityCreated() is called, which is guaranteed to happen after both. - */ - @Override - public void onActivityCreated(final Bundle savedInstanceState) { - super.onActivityCreated(savedInstanceState); - // Delay showing the message list until the participant list is loaded. - mRecyclerView.setVisibility(View.INVISIBLE); - mBinding.ensureBound(); - mBinding.getData().init(LoaderManager.getInstance(this), mBinding); - - // Build the input manager with all its required dependencies and pass it along to the - // compose message view. - final ConversationInputManager inputManager = new ConversationInputManager( - getActivity(), this, mComposeMessageView, mHost, getFragmentManagerToUse(), - mBinding, mComposeMessageView.getDraftDataModel(), savedInstanceState); - mComposeMessageView.setInputManager(inputManager); - mComposeMessageView.setConversationDataModel(BindingBase.createBindingReference(mBinding)); - mHost.invalidateActionBar(); - - mDraftMessageDataModel = - BindingBase.createBindingReference(mComposeMessageView.getDraftDataModel()); - mDraftMessageDataModel.getData().addListener(this); - } - - public void onAttachmentChoosen() { - // Attachment has been choosen in the AttachmentChooserActivity, so clear local draft - // and reload draft on resume. - mClearLocalDraft = true; - } - - private int getScrollToMessagePosition() { - final Activity activity = getActivity(); - if (activity == null) { - return -1; - } - - final Intent intent = activity.getIntent(); - if (intent == null) { - return -1; - } - - return intent.getIntExtra(UIIntents.UI_INTENT_EXTRA_MESSAGE_POSITION, -1); - } - - private void clearScrollToMessagePosition() { - final Activity activity = getActivity(); - if (activity == null) { - return; - } - - final Intent intent = activity.getIntent(); - if (intent == null) { - return; - } - intent.putExtra(UIIntents.UI_INTENT_EXTRA_MESSAGE_POSITION, -1); - } - - private final Handler mHandler = new Handler(); - - /** - * {@inheritDoc} from Fragment - */ - @Override - public View onCreateView(final LayoutInflater inflater, final ViewGroup container, - final Bundle savedInstanceState) { - final View view = inflater.inflate(R.layout.conversation_fragment, container, false); - mRecyclerView = (RecyclerView) view.findViewById(android.R.id.list); - final LinearLayoutManager manager = new LinearLayoutManager(getActivity()); - manager.setStackFromEnd(true); - manager.setReverseLayout(false); - mRecyclerView.setHasFixedSize(true); - mRecyclerView.setLayoutManager(manager); - mRecyclerView.setItemAnimator(new DefaultItemAnimator() { - private final List mAddAnimations = new ArrayList(); - private PopupTransitionAnimation mPopupTransitionAnimation; - - @Override - public boolean animateAdd(final ViewHolder holder) { - final ConversationMessageView view = - (ConversationMessageView) holder.itemView; - final ConversationMessageData data = view.getData(); - endAnimation(holder); - final long timeSinceSend = System.currentTimeMillis() - data.getReceivedTimeStamp(); - if (data.getReceivedTimeStamp() == - InsertNewMessageAction.getLastSentMessageTimestamp() && - !data.getIsIncoming() && - timeSinceSend < MESSAGE_ANIMATION_MAX_WAIT) { - final ConversationMessageBubbleView messageBubble = - (ConversationMessageBubbleView) view - .findViewById(R.id.message_content); - final Rect startRect = UiUtils.getMeasuredBoundsOnScreen(mComposeMessageView); - final View composeBubbleView = mComposeMessageView.findViewById( - R.id.compose_message_text); - final Rect composeBubbleRect = - UiUtils.getMeasuredBoundsOnScreen(composeBubbleView); - final AttachmentPreview attachmentView = - (AttachmentPreview) mComposeMessageView.findViewById( - R.id.attachment_draft_view); - final Rect attachmentRect = UiUtils.getMeasuredBoundsOnScreen(attachmentView); - if (attachmentView.getVisibility() == View.VISIBLE) { - startRect.top = attachmentRect.top; - } else { - startRect.top = composeBubbleRect.top; - } - startRect.top -= view.getPaddingTop(); - startRect.bottom = - composeBubbleRect.bottom; - startRect.left += view.getPaddingRight(); - - view.setAlpha(0); - mPopupTransitionAnimation = new PopupTransitionAnimation(startRect, view); - mPopupTransitionAnimation.setOnStartCallback(new Runnable() { - @Override - public void run() { - final int startWidth = composeBubbleRect.width(); - attachmentView.onMessageAnimationStart(); - messageBubble.kickOffMorphAnimation(startWidth, - messageBubble.findViewById(R.id.message_text_and_info) - .getMeasuredWidth()); - } - }); - mPopupTransitionAnimation.setOnStopCallback(new Runnable() { - @Override - public void run() { - view.setAlpha(1); - dispatchAddFinished(holder); - } - }); - mPopupTransitionAnimation.startAfterLayoutComplete(); - mAddAnimations.add(holder); - return true; - } else { - return super.animateAdd(holder); - } - } - - @Override - public void endAnimation(final ViewHolder holder) { - if (mAddAnimations.remove(holder)) { - holder.itemView.clearAnimation(); - } - super.endAnimation(holder); - } - - @Override - public void endAnimations() { - for (final ViewHolder holder : mAddAnimations) { - holder.itemView.clearAnimation(); - } - mAddAnimations.clear(); - if (mPopupTransitionAnimation != null) { - mPopupTransitionAnimation.cancel(); - } - super.endAnimations(); - } - }); - mRecyclerView.setAdapter(mAdapter); - - if (savedInstanceState != null) { - mListState = savedInstanceState.getParcelable(SAVED_INSTANCE_STATE_LIST_VIEW_STATE_KEY); - } - - mConversationComposeDivider = view.findViewById(R.id.conversation_compose_divider); - mScrollToDismissThreshold = ViewConfiguration.get(getActivity()).getScaledTouchSlop(); - mRecyclerView.addOnScrollListener(mListScrollListener); - mFastScroller = ConversationFastScroller.addTo(mRecyclerView, - UiUtils.isRtlMode() ? ConversationFastScroller.POSITION_LEFT_SIDE : - ConversationFastScroller.POSITION_RIGHT_SIDE); - - mComposeMessageView = (ComposeMessageView) - view.findViewById(R.id.message_compose_view_container); - // Bind the compose message view to the DraftMessageData - mComposeMessageView.bind(DataModel.get().createDraftMessageData( - mBinding.getData().getConversationId()), this); - - return view; - } - - private void scrollToPosition(final int targetPosition, final boolean smoothScroll) { - if (smoothScroll) { - final int maxScrollDelta = JUMP_SCROLL_THRESHOLD; - - final LinearLayoutManager layoutManager = - (LinearLayoutManager) mRecyclerView.getLayoutManager(); - final int firstVisibleItemPosition = - layoutManager.findFirstVisibleItemPosition(); - final int delta = targetPosition - firstVisibleItemPosition; - final int intermediatePosition; - - if (delta > maxScrollDelta) { - intermediatePosition = Math.max(0, targetPosition - maxScrollDelta); - } else if (delta < -maxScrollDelta) { - final int count = layoutManager.getItemCount(); - intermediatePosition = Math.min(count - 1, targetPosition + maxScrollDelta); - } else { - intermediatePosition = -1; - } - if (intermediatePosition != -1) { - mRecyclerView.scrollToPosition(intermediatePosition); - } - mRecyclerView.smoothScrollToPosition(targetPosition); - } else { - mRecyclerView.scrollToPosition(targetPosition); - } - } - - private int getScrollPositionFromBottom() { - final LinearLayoutManager layoutManager = - (LinearLayoutManager) mRecyclerView.getLayoutManager(); - final int lastVisibleItem = - layoutManager.findLastVisibleItemPosition(); - return Math.max(mAdapter.getItemCount() - 1 - lastVisibleItem, 0); - } - - /** - * Display a photo using the Photoviewer component. - */ - @Override - public void displayPhoto(final Uri photoUri, final Rect imageBounds, final boolean isDraft) { - displayPhoto(photoUri, imageBounds, isDraft, mConversationId, getActivity()); - } - - public static void displayPhoto(final Uri photoUri, final Rect imageBounds, - final boolean isDraft, final String conversationId, final Activity activity) { - final Uri imagesUri = - isDraft ? MessagingContentProvider.buildDraftImagesUri(conversationId) - : MessagingContentProvider.buildConversationImagesUri(conversationId); - UIIntents.get().launchFullScreenPhotoViewer( - activity, photoUri, imageBounds, imagesUri); - } - - private void selectMessage(final ConversationMessageView messageView) { - selectMessage(messageView, null /* attachment */); - } - - private void selectMessage(final ConversationMessageView messageView, - final MessagePartData attachment) { - mSelectedMessage = messageView; - if (mSelectedMessage == null) { - mAdapter.setSelectedMessage(null); - mHost.dismissActionMode(); - mSelectedAttachment = null; - return; - } - mSelectedAttachment = attachment; - mAdapter.setSelectedMessage(messageView.getData().getMessageId()); - mHost.startActionMode(mMessageActionModeCallback); - } - - @Override - public void onSaveInstanceState(final Bundle outState) { - super.onSaveInstanceState(outState); - if (mListState != null) { - outState.putParcelable(SAVED_INSTANCE_STATE_LIST_VIEW_STATE_KEY, mListState); - } - mComposeMessageView.saveInputState(outState); - } - - @Override - public void onResume() { - super.onResume(); - - if (mIncomingDraft == null) { - mComposeMessageView.requestDraftMessage(mClearLocalDraft); - } else { - mComposeMessageView.setDraftMessage(mIncomingDraft); - mIncomingDraft = null; - } - mClearLocalDraft = false; - - // On resume, check if there's a pending request for resuming message compose. This - // may happen when the user commits the contact selection for a group conversation and - // goes from compose back to the conversation fragment. - if (mHost.shouldResumeComposeMessage()) { - mComposeMessageView.resumeComposeMessage(); - } - - setConversationFocus(); - - // On resume, invalidate all message views to show the updated timestamp. - mAdapter.notifyDataSetChanged(); - - LocalBroadcastManager.getInstance(getActivity()).registerReceiver( - mConversationSelfIdChangeReceiver, - new IntentFilter(UIIntents.CONVERSATION_SELF_ID_CHANGE_BROADCAST_ACTION)); - } - - void setConversationFocus() { - // Cancelling notifications causes bubbles to be removed from the screen - if (mHost.isActiveAndFocused()) { - mBinding.getData().setFocus(!getActivity().isLaunchedFromBubble()); - } - } - - @Override - public void onCreateOptionsMenu(final Menu menu, final MenuInflater inflater) { - if (mHost.getActionMode() != null) { - return; - } - - inflater.inflate(R.menu.conversation_menu, menu); - - final ConversationData data = mBinding.getData(); - - // Disable the "people & options" item if we haven't loaded participants yet. - menu.findItem(R.id.action_people_and_options).setEnabled(data.getParticipantsLoaded()); - - // See if we can show add contact action. - final ParticipantData participant = data.getOtherParticipant(); - final boolean addContactActionVisible = (participant != null - && TextUtils.isEmpty(participant.getLookupKey())); - menu.findItem(R.id.action_add_contact).setVisible(addContactActionVisible); - - // See if we should show archive or unarchive. - final boolean isArchived = data.getIsArchived(); - menu.findItem(R.id.action_archive).setVisible(!isArchived); - menu.findItem(R.id.action_unarchive).setVisible(isArchived); - - // Conditionally enable the phone call button. - final boolean supportCallAction = (PhoneUtils.getDefault().isVoiceCapable() && - data.getParticipantPhoneNumber() != null); - menu.findItem(R.id.action_call).setVisible(supportCallAction); - } - - @Override - public boolean onOptionsItemSelected(final MenuItem item) { - int itemId = item.getItemId(); - if (itemId == R.id.action_people_and_options) { - Assert.isTrue(mBinding.getData().getParticipantsLoaded()); - UIIntents.get().launchPeopleAndOptionsActivity(getActivity(), mConversationId); - return true; - } - if (itemId == R.id.action_call) { - final String phoneNumber = mBinding.getData().getParticipantPhoneNumber(); - Assert.notNull(phoneNumber); - // Can't make a call to emergency numbers using ACTION_CALL. - if (PhoneNumberUtils.isEmergencyNumber(phoneNumber)) { - UiUtils.showToast(R.string.disallow_emergency_call); - } - else { - final View targetView = getActivity().findViewById(R.id.action_call); - Point centerPoint; - if (targetView != null) { - final int screenLocation[] = new int[2]; - targetView.getLocationOnScreen(screenLocation); - final int centerX = screenLocation[0] + targetView.getWidth() / 2; - final int centerY = screenLocation[1] + targetView.getHeight() / 2; - centerPoint = new Point(centerX, centerY); - } - else { - // In the overflow menu, just use the center of the screen. - final Display display = - getActivity().getWindowManager().getDefaultDisplay(); - centerPoint = new Point(display.getWidth() / 2, display.getHeight() / 2); - } - UIIntents.get() - .launchPhoneCallActivity(getActivity(), phoneNumber, centerPoint); - } - return true; - } - if (itemId == R.id.action_archive) { - mBinding.getData().archiveConversation(mBinding); - closeConversation(mConversationId); - return true; - } - if (itemId == R.id.action_unarchive) { - mBinding.getData().unarchiveConversation(mBinding); - return true; - } - if (itemId == R.id.action_settings) { - return true; - } - if (itemId == R.id.action_add_contact) { - final ParticipantData participant = mBinding.getData().getOtherParticipant(); - Assert.notNull(participant); - final String destination = participant.getNormalizedDestination(); - final Uri avatarUri = AvatarUriUtil.createAvatarUri(participant); - (new AddContactsConfirmationDialog(getActivity(), avatarUri, destination)).show(); - return true; - } - if (itemId == R.id.action_delete) { - if (isReadyForAction()) { - new AlertDialog.Builder(getActivity()) - .setTitle(getResources().getQuantityString( - R.plurals.delete_conversations_confirmation_dialog_title, 1)) - .setPositiveButton(R.string.delete_conversation_confirmation_button, - new OnClickListener() { - @Override - public void onClick(final DialogInterface dialog, - final int button) { - deleteConversation(); - } - }) - .setNegativeButton(R.string.delete_conversation_decline_button, null) - .show(); - } - else { - warnOfMissingActionConditions(false /*sending*/, - null /*commandToRunAfterActionConditionResolved*/); - } - return true; - } - return super.onOptionsItemSelected(item); - } - - /** - * {@inheritDoc} from ConversationDataListener - */ - @Override - public void onConversationMessagesCursorUpdated(final ConversationData data, - final Cursor cursor, final ConversationMessageData newestMessage, - final boolean isSync) { - mBinding.ensureBound(data); - - // This needs to be determined before swapping cursor, which may change the scroll state. - final boolean scrolledToBottom = isScrolledToBottom(); - final int positionFromBottom = getScrollPositionFromBottom(); - - // If participants not loaded, assume 1:1 since that's the 99% case - final boolean oneOnOne = - !data.getParticipantsLoaded() || data.getOtherParticipant() != null; - mAdapter.setOneOnOne(oneOnOne, false /* invalidate */); - - // Ensure that the action bar is updated with the current data. - invalidateOptionsMenu(); - final Cursor oldCursor = mAdapter.swapCursor(cursor); - - if (cursor != null && oldCursor == null) { - if (mListState != null) { - mRecyclerView.getLayoutManager().onRestoreInstanceState(mListState); - // RecyclerView restores scroll states without triggering scroll change events, so - // we need to manually ensure that they are correctly handled. - mListScrollListener.onScrolled(mRecyclerView, 0, 0); - } - } - - if (isSync) { - // This is a message sync. Syncing messages changes cursor item count, which would - // implicitly change RV's scroll position. We'd like the RV to keep scrolled to the same - // relative position from the bottom (because RV is stacked from bottom), so that it - // stays relatively put as we sync. - final int position = Math.max(mAdapter.getItemCount() - 1 - positionFromBottom, 0); - scrollToPosition(position, false /* smoothScroll */); - } else if (newestMessage != null) { - // Show a snack bar notification if we are not scrolled to the bottom and the new - // message is an incoming message. - if (!scrolledToBottom && newestMessage.getIsIncoming()) { - // If the conversation activity is started but not resumed (if another dialog - // activity was in the foregrond), we will show a system notification instead of - // the snack bar. - if (mBinding.getData().isFocused()) { - UiUtils.showSnackBarWithCustomAction(getActivity(), - getView().getRootView(), - getString(R.string.in_conversation_notify_new_message_text), - SnackBar.Action.createCustomAction(new Runnable() { - @Override - public void run() { - scrollToBottom(true /* smoothScroll */); - mComposeMessageView.hideAllComposeInputs(false /* animate */); - } - }, - getString(R.string.in_conversation_notify_new_message_action)), - null /* interactions */, - SnackBar.Placement.above(mComposeMessageView)); - } - } else { - // We are either already scrolled to the bottom or this is an outgoing message, - // scroll to the bottom to reveal it. - // Don't smooth scroll if we were already at the bottom; instead, we scroll - // immediately so RecyclerView's view animation will take place. - scrollToBottom(!scrolledToBottom); - } - } - - if (cursor != null) { - mHost.onConversationMessagesUpdated(cursor.getCount()); - - // Are we coming from a widget click where we're told to scroll to a particular item? - final int scrollToPos = getScrollToMessagePosition(); - if (scrollToPos >= 0) { - if (LogUtil.isLoggable(LogUtil.BUGLE_TAG, LogUtil.VERBOSE)) { - LogUtil.v(LogUtil.BUGLE_TAG, "onConversationMessagesCursorUpdated " + - " scrollToPos: " + scrollToPos + - " cursorCount: " + cursor.getCount()); - } - scrollToPosition(scrollToPos, true /*smoothScroll*/); - clearScrollToMessagePosition(); - } - } - - mHost.invalidateActionBar(); - } - - /** - * {@inheritDoc} from ConversationDataListener - */ - @Override - public void onConversationMetadataUpdated(final ConversationData conversationData) { - mBinding.ensureBound(conversationData); - - if (mSelectedMessage != null && mSelectedAttachment != null) { - // We may have just sent a message and the temp attachment we selected is now gone. - // and it was replaced with some new attachment. Since we don't know which one it - // is we shouldn't reselect it (unless there is just one) In the multi-attachment - // case we would just deselect the message and allow the user to reselect, otherwise we - // may act on old temp data and may crash. - final List currentAttachments = mSelectedMessage.getData().getAttachments(); - if (currentAttachments.size() == 1) { - mSelectedAttachment = currentAttachments.get(0); - } else if (!currentAttachments.contains(mSelectedAttachment)) { - selectMessage(null); - } - } - // Ensure that the action bar is updated with the current data. - invalidateOptionsMenu(); - mHost.onConversationMetadataUpdated(); - mAdapter.notifyDataSetChanged(); - } - - public void setConversationInfo(final Context context, final String conversationId, - final MessageData draftData) { - // TODO: Eventually I would like the Factory to implement - // Factory.get().bindConversationData(mBinding, getActivity(), this, conversationId)); - if (!mBinding.isBound()) { - mConversationId = conversationId; - mIncomingDraft = draftData; - mBinding.bind(DataModel.get().createConversationData(context, this, conversationId)); - } else { - Assert.isTrue(TextUtils.equals(mBinding.getData().getConversationId(), conversationId)); - } - } - - @Override - public void onDestroy() { - super.onDestroy(); - // Unbind all the views that we bound to data - if (mComposeMessageView != null) { - mComposeMessageView.unbind(); - } - - // And unbind this fragment from its data - mBinding.unbind(); - mConversationId = null; - } - - void suppressWriteDraft() { - mSuppressWriteDraft = true; - } - - @Override - public void onPause() { - super.onPause(); - if (mComposeMessageView != null && !mSuppressWriteDraft) { - mComposeMessageView.writeDraftMessage(); - } - mSuppressWriteDraft = false; - mBinding.getData().unsetFocus(); - mListState = mRecyclerView.getLayoutManager().onSaveInstanceState(); - - LocalBroadcastManager.getInstance(getActivity()) - .unregisterReceiver(mConversationSelfIdChangeReceiver); - } - - @Override - public void onConfigurationChanged(final Configuration newConfig) { - super.onConfigurationChanged(newConfig); - mRecyclerView.getItemAnimator().endAnimations(); - } - - // TODO: Remove isBound and replace it with ensureBound after b/15704674. - public boolean isBound() { - return mBinding.isBound(); - } - - private FragmentManager getFragmentManagerToUse() { - return getChildFragmentManager(); - } - - public MediaPicker getMediaPicker() { - return (MediaPicker) getFragmentManagerToUse().findFragmentByTag( - MediaPicker.FRAGMENT_TAG); - } - - @Override - public void sendMessage(final MessageData message) { - if (isReadyForAction()) { - if (ensureKnownRecipients()) { - // Merge the caption text from attachments into the text body of the messages - message.consolidateText(); - - mBinding.getData().sendMessage(mBinding, message); - mComposeMessageView.resetMediaPickerState(); - } else { - LogUtil.w(LogUtil.BUGLE_TAG, "Message can't be sent: conv participants not loaded"); - } - } else { - warnOfMissingActionConditions(true /*sending*/, - new Runnable() { - @Override - public void run() { - sendMessage(message); - } - }); - } - } - - public void setHost(final ConversationFragmentHost host) { - mHost = host; - } - - public String getConversationName() { - return mBinding.getData().getConversationName(); - } - - @Override - public void onComposeEditTextFocused() { - mHost.onStartComposeMessage(); - } - - @Override - public void onAttachmentsCleared() { - // When attachments are removed, reset transient media picker state such as image selection. - mComposeMessageView.resetMediaPickerState(); - } - - /** - * Called to check if all conditions are nominal and a "go" for some action, such as deleting - * a message, that requires this app to be the default app. This is also a precondition - * required for sending a draft. - * @return true if all conditions are nominal and we're ready to send a message - */ - @Override - public boolean isReadyForAction() { - return UiUtils.isReadyForAction(); - } - - /** - * When there's some condition that prevents an operation, such as sending a message, - * call warnOfMissingActionConditions to put up a snackbar and allow the user to repair - * that condition. - * @param sending - true if we're called during a sending operation - * @param commandToRunAfterActionConditionResolved - a runnable to run after the user responds - * positively to the condition prompt and resolves the condition. If null, - * the user will be shown a toast to tap the send button again. - */ - @Override - public void warnOfMissingActionConditions(final boolean sending, - final Runnable commandToRunAfterActionConditionResolved) { - if (mChangeDefaultSmsAppHelper == null) { - mChangeDefaultSmsAppHelper = new ChangeDefaultSmsAppHelper(); - } - mChangeDefaultSmsAppHelper.warnOfMissingActionConditions(sending, - commandToRunAfterActionConditionResolved, mComposeMessageView, - getView().getRootView(), - getActivity(), this); - } - - private boolean ensureKnownRecipients() { - final ConversationData conversationData = mBinding.getData(); - - if (!conversationData.getParticipantsLoaded()) { - // We can't tell yet whether or not we have an unknown recipient - return false; - } - - final ConversationParticipantsData participants = conversationData.getParticipants(); - for (final ParticipantData participant : participants) { - - - if (participant.isUnknownSender()) { - UiUtils.showToast(R.string.unknown_sender); - return false; - } - } - - return true; - } - - public void retryDownload(final String messageId) { - if (isReadyForAction()) { - mBinding.getData().downloadMessage(mBinding, messageId); - } else { - warnOfMissingActionConditions(false /*sending*/, - null /*commandToRunAfterActionConditionResolved*/); - } - } - - public void retrySend(final String messageId) { - if (isReadyForAction()) { - if (ensureKnownRecipients()) { - mBinding.getData().resendMessage(mBinding, messageId); - } - } else { - warnOfMissingActionConditions(true /*sending*/, - new Runnable() { - @Override - public void run() { - retrySend(messageId); - } - - }); - } - } - - void deleteMessage(final String messageId) { - if (isReadyForAction()) { - final AlertDialog.Builder builder = new AlertDialog.Builder(getActivity()) - .setTitle(R.string.delete_message_confirmation_dialog_title) - .setMessage(R.string.delete_message_confirmation_dialog_text) - .setPositiveButton(R.string.delete_message_confirmation_button, - new OnClickListener() { - @Override - public void onClick(final DialogInterface dialog, final int which) { - mBinding.getData().deleteMessage(mBinding, messageId); - } - }) - .setNegativeButton(android.R.string.cancel, null); - - builder.setOnDismissListener(dialog -> mHost.dismissActionMode()); - builder.create().show(); - } else { - warnOfMissingActionConditions(false /*sending*/, - null /*commandToRunAfterActionConditionResolved*/); - mHost.dismissActionMode(); - } - } - - public void deleteConversation() { - if (isReadyForAction()) { - final Context context = getActivity(); - mBinding.getData().deleteConversation(mBinding); - closeConversation(mConversationId); - } else { - warnOfMissingActionConditions(false /*sending*/, - null /*commandToRunAfterActionConditionResolved*/); - } - } - - @Override - public void closeConversation(final String conversationId) { - if (TextUtils.equals(conversationId, mConversationId)) { - mHost.onFinishCurrentConversation(); - // TODO: Explicitly transition to ConversationList (or just go back)? - } - } - - @Override - public void onConversationParticipantDataLoaded(final ConversationData data) { - mBinding.ensureBound(data); - if (mBinding.getData().getParticipantsLoaded()) { - final boolean oneOnOne = mBinding.getData().getOtherParticipant() != null; - mAdapter.setOneOnOne(oneOnOne, true /* invalidate */); - - // refresh the options menu which will enable the "people & options" item. - invalidateOptionsMenu(); - - mHost.invalidateActionBar(); - - mRecyclerView.setVisibility(View.VISIBLE); - mHost.onConversationParticipantDataLoaded - (mBinding.getData().getNumberOfParticipantsExcludingSelf()); - } - } - - @Override - public void onSubscriptionListDataLoaded(final ConversationData data) { - mBinding.ensureBound(data); - mAdapter.notifyDataSetChanged(); - } - - @Override - public void promptForSelfPhoneNumber() { - if (mComposeMessageView != null) { - // Avoid bug in system which puts soft keyboard over dialog after orientation change - ImeUtil.hideSoftInput(requireActivity(), mComposeMessageView); - } - - final FragmentTransaction ft = requireActivity().getSupportFragmentManager().beginTransaction(); - final EnterSelfPhoneNumberDialog dialog = EnterSelfPhoneNumberDialog - .newInstance(getConversationSelfSubId()); - dialog.setTargetFragment(this, 0/*requestCode*/); - dialog.show(ft, null/*tag*/); - } - - @Override - public void onActivityResult(final int requestCode, final int resultCode, final Intent data) { - if (mChangeDefaultSmsAppHelper == null) { - mChangeDefaultSmsAppHelper = new ChangeDefaultSmsAppHelper(); - } - mChangeDefaultSmsAppHelper.handleChangeDefaultSmsResult(requestCode, resultCode, null); - } - - public boolean hasMessages() { - return mAdapter != null && mAdapter.getItemCount() > 0; - } - - public boolean onBackPressed() { - if (mComposeMessageView.onBackPressed()) { - return true; - } - return false; - } - - public boolean onNavigationUpPressed() { - return mComposeMessageView.onNavigationUpPressed(); - } - - @Override - public boolean onAttachmentClick(final ConversationMessageView messageView, - final MessagePartData attachment, final Rect imageBounds, final boolean longPress) { - if (longPress) { - selectMessage(messageView, attachment); - return true; - } else if (messageView.getData().getOneClickResendMessage()) { - handleMessageClick(messageView); - return true; - } - - if (attachment.isImage()) { - displayPhoto(attachment.getContentUri(), imageBounds, false /* isDraft */); - } - - if (attachment.isVCard()) { - UIIntents.get().launchVCardDetailActivity(getActivity(), attachment.getContentUri()); - } - - return false; - } - - private void handleMessageClick(final ConversationMessageView messageView) { - if (messageView != mSelectedMessage) { - final ConversationMessageData data = messageView.getData(); - final boolean isReadyToSend = isReadyForAction(); - if (data.getOneClickResendMessage()) { - // Directly resend the message on tap if it's failed - retrySend(data.getMessageId()); - selectMessage(null); - } else if (data.getShowResendMessage() && isReadyToSend) { - // Select the message to show the resend/download/delete options - selectMessage(messageView); - } else if (data.getShowDownloadMessage() && isReadyToSend) { - // Directly download the message on tap - retryDownload(data.getMessageId()); - } else { - // Let the toast from warnOfMissingActionConditions show and skip - // selecting - warnOfMissingActionConditions(false /*sending*/, - null /*commandToRunAfterActionConditionResolved*/); - selectMessage(null); - } - } else { - selectMessage(null); - } - } - - private static class AttachmentToSave { - public final Uri uri; - public final String contentType; - public Uri persistedUri; - - AttachmentToSave(final Uri uri, final String contentType) { - this.uri = uri; - this.contentType = contentType; - } - } - - public static class SaveAttachmentTask extends SafeAsyncTask { - private final Context mContext; - private final List mAttachmentsToSave = new ArrayList<>(); - - public SaveAttachmentTask(final Context context, final Uri contentUri, - final String contentType) { - mContext = context; - addAttachmentToSave(contentUri, contentType); - } - - public SaveAttachmentTask(final Context context) { - mContext = context; - } - - public void addAttachmentToSave(final Uri contentUri, final String contentType) { - mAttachmentsToSave.add(new AttachmentToSave(contentUri, contentType)); - } - - public int getAttachmentCount() { - return mAttachmentsToSave.size(); - } - - @Override - protected Void doInBackgroundTimed(final Void... arg) { - final File appDir = new File(Environment.getExternalStoragePublicDirectory( - Environment.DIRECTORY_PICTURES), - mContext.getResources().getString(R.string.app_name)); - final File downloadDir = Environment.getExternalStoragePublicDirectory( - Environment.DIRECTORY_DOWNLOADS); - for (final AttachmentToSave attachment : mAttachmentsToSave) { - final boolean isImageOrVideo = ContentType.isImageType(attachment.contentType) - || ContentType.isVideoType(attachment.contentType); - attachment.persistedUri = UriUtil.persistContent(attachment.uri, - isImageOrVideo ? appDir : downloadDir, attachment.contentType); - } - return null; - } - - @Override - protected void onPostExecute(final Void result) { - int failCount = 0; - int imageCount = 0; - int videoCount = 0; - int otherCount = 0; - for (final AttachmentToSave attachment : mAttachmentsToSave) { - if (attachment.persistedUri == null) { - failCount++; - continue; - } - - // Inform MediaScanner about the new file - final Intent scanFileIntent = new Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE); - scanFileIntent.setData(attachment.persistedUri); - mContext.sendBroadcast(scanFileIntent); - - if (ContentType.isImageType(attachment.contentType)) { - imageCount++; - } else if (ContentType.isVideoType(attachment.contentType)) { - videoCount++; - } else { - otherCount++; - // Inform DownloadManager of the file so it will show in the "downloads" app - final DownloadManager downloadManager = - (DownloadManager) mContext.getSystemService( - Context.DOWNLOAD_SERVICE); - final String filePath = attachment.persistedUri.getPath(); - final File file = new File(filePath); - - if (file.exists()) { - downloadManager.addCompletedDownload( - file.getName() /* title */, - mContext.getString( - R.string.attachment_file_description) /* description */, - true /* isMediaScannerScannable */, - attachment.contentType, - file.getAbsolutePath(), - file.length(), - false /* showNotification */); - } - } - } - - String message; - if (failCount > 0) { - message = mContext.getResources().getQuantityString( - R.plurals.attachment_save_error, failCount, failCount); - } else { - int messageId = R.plurals.attachments_saved; - if (otherCount > 0) { - if (imageCount + videoCount == 0) { - messageId = R.plurals.attachments_saved_to_downloads; - } - } else { - if (videoCount == 0) { - messageId = R.plurals.photos_saved_to_album; - } else if (imageCount == 0) { - messageId = R.plurals.videos_saved_to_album; - } else { - messageId = R.plurals.attachments_saved_to_album; - } - } - final String appName = mContext.getResources().getString(R.string.app_name); - final int count = imageCount + videoCount + otherCount; - message = mContext.getResources().getQuantityString( - messageId, count, count, appName); - } - UiUtils.showToastAtBottom(message); - } - } - - private void invalidateOptionsMenu() { - final Activity activity = getActivity(); - // TODO: Add the supportInvalidateOptionsMenu call to the host activity. - if (activity == null || !(activity instanceof BugleActionBarActivity)) { - return; - } - ((BugleActionBarActivity) activity).supportInvalidateOptionsMenu(); - } - - @Override - public void setOptionsMenuVisibility(final boolean visible) { - setHasOptionsMenu(visible); - } - - @Override - public int getConversationSelfSubId() { - final String selfParticipantId = mComposeMessageView.getConversationSelfId(); - final ParticipantData self = mBinding.getData().getSelfParticipantById(selfParticipantId); - // If the self id or the self participant data hasn't been loaded yet, fallback to - // the default setting. - return self == null ? ParticipantData.DEFAULT_SELF_SUB_ID : self.getSubId(); - } - - @Override - public void invalidateActionBar() { - mHost.invalidateActionBar(); - } - - @Override - public void dismissActionMode() { - mHost.dismissActionMode(); - } - - @Override - public void selectSim(final SubscriptionListEntry subscriptionData) { - mComposeMessageView.selectSim(subscriptionData); - mHost.onStartComposeMessage(); - } - - @Override - public void onStartComposeMessage() { - mHost.onStartComposeMessage(); - } - - @Override - public SubscriptionListEntry getSubscriptionEntryForSelfParticipant( - final String selfParticipantId, final boolean excludeDefault) { - // TODO: ConversationMessageView is the only one using this. We should probably - // inject this into the view during binding in the ConversationMessageAdapter. - return mBinding.getData().getSubscriptionEntryForSelfParticipant(selfParticipantId, - excludeDefault); - } - - @Override - public SimSelectorView getSimSelectorView() { - return (SimSelectorView) getView().findViewById(R.id.sim_selector); - } - - @Override - public MediaPicker createMediaPicker() { - return new MediaPicker(getActivity()); - } - - @Override - public void notifyOfAttachmentLoadFailed() { - UiUtils.showToastAtBottom(R.string.attachment_load_failed_dialog_message); - } - - @Override - public void warnOfExceedingMessageLimit(final boolean sending, final boolean tooManyVideos) { - warnOfExceedingMessageLimit(sending, mComposeMessageView, mConversationId, - getActivity(), tooManyVideos); - } - - public static void warnOfExceedingMessageLimit(final boolean sending, - final ComposeMessageView composeMessageView, final String conversationId, - final Activity activity, final boolean tooManyVideos) { - final AlertDialog.Builder builder = - new AlertDialog.Builder(activity) - .setTitle(R.string.mms_attachment_limit_reached); - - if (sending) { - if (tooManyVideos) { - builder.setMessage(R.string.video_attachment_limit_exceeded_when_sending); - } else { - builder.setMessage(R.string.attachment_limit_reached_dialog_message_when_sending) - .setNegativeButton(R.string.attachment_limit_reached_send_anyway, - new OnClickListener() { - @Override - public void onClick(final DialogInterface dialog, - final int which) { - composeMessageView.sendMessageIgnoreMessageSizeLimit(); - } - }); - } - builder.setPositiveButton(android.R.string.ok, new OnClickListener() { - @Override - public void onClick(final DialogInterface dialog, final int which) { - showAttachmentChooser(conversationId, activity); - }}); - } else { - builder.setMessage(R.string.attachment_limit_reached_dialog_message_when_composing) - .setPositiveButton(android.R.string.ok, null); - } - builder.show(); - } - - @Override - public void showAttachmentChooser() { - showAttachmentChooser(mConversationId, getActivity()); - } - - public static void showAttachmentChooser(final String conversationId, - final Activity activity) { - UIIntents.get().launchAttachmentChooserActivity(activity, - conversationId, REQUEST_CHOOSE_ATTACHMENTS); - } - - private void updateActionAndStatusBarColor(final ActionBar actionBar) { - final int themeColor = ConversationDrawables.get().getConversationThemeColor(); - actionBar.setBackgroundDrawable(new ColorDrawable(themeColor)); - UiUtils.setStatusBarColor(getActivity(), themeColor); - } - - public void updateActionBar(final ActionBar actionBar) { - if (mComposeMessageView == null || !mComposeMessageView.updateActionBar(actionBar)) { - updateActionAndStatusBarColor(actionBar); - // We update this regardless of whether or not the action bar is showing so that we - // don't get a race when it reappears. - actionBar.setDisplayOptions(ActionBar.DISPLAY_SHOW_CUSTOM); - actionBar.setDisplayHomeAsUpEnabled(true); - // Reset the back arrow to its default - actionBar.setHomeAsUpIndicator(0); - View customView = actionBar.getCustomView(); - if (customView == null || customView.getId() != R.id.conversation_title_container) { - final LayoutInflater inflator = (LayoutInflater) - getActivity().getSystemService(Context.LAYOUT_INFLATER_SERVICE); - customView = inflator.inflate(R.layout.action_bar_conversation_name, null); - customView.setOnClickListener(new View.OnClickListener() { - @Override - public void onClick(final View v) { - onBackPressed(); - } - }); - actionBar.setCustomView(customView); - } - - final TextView conversationNameView = - (TextView) customView.findViewById(R.id.conversation_title); - final String conversationName = getConversationName(); - if (!TextUtils.isEmpty(conversationName)) { - // RTL : To format conversation title if it happens to be phone numbers. - final BidiFormatter bidiFormatter = BidiFormatter.getInstance(); - final String formattedName = bidiFormatter.unicodeWrap( - UiUtils.commaEllipsize( - conversationName, - conversationNameView.getPaint(), - conversationNameView.getWidth(), - getString(R.string.plus_one), - getString(R.string.plus_n)).toString(), - TextDirectionHeuristicsCompat.LTR); - conversationNameView.setText(formattedName); - // In case phone numbers are mixed in the conversation name, we need to vocalize it. - final String vocalizedConversationName = - AccessibilityUtil.getVocalizedPhoneNumber(getResources(), conversationName); - conversationNameView.setContentDescription(vocalizedConversationName); - getActivity().setTitle(conversationName); - } else { - final String appName = getString(R.string.app_name); - conversationNameView.setText(appName); - getActivity().setTitle(appName); - } - - // When conversation is showing and media picker is not showing, then hide the action - // bar only when we are in landscape mode, with IME open. - if (mHost.isImeOpen() && UiUtils.isLandscapeMode()) { - actionBar.hide(); - } else { - actionBar.show(); - } - } - } - - @Override - public boolean shouldShowSubjectEditor() { - return true; - } - - @Override - public boolean shouldHideAttachmentsWhenSimSelectorShown() { - return false; - } - - @Override - public void showHideSimSelector(final boolean show) { - // no-op for now - } - - @Override - public int getSimSelectorItemLayoutId() { - return R.layout.sim_selector_item_view; - } - - @Override - public Uri getSelfSendButtonIconUri() { - return null; // use default button icon uri - } - - @Override - public int overrideCounterColor() { - return -1; // don't override the color - } - - @Override - public void onAttachmentsChanged(final boolean haveAttachments) { - // no-op for now - } - - @Override - public void onDraftChanged(final DraftMessageData data, final int changeFlags) { - mDraftMessageDataModel.ensureBound(data); - // We're specifically only interested in ATTACHMENTS_CHANGED from the widget. Ignore - // other changes. When the widget changes an attachment, we need to reload the draft. - if (changeFlags == - (DraftMessageData.WIDGET_CHANGED | DraftMessageData.ATTACHMENTS_CHANGED)) { - mClearLocalDraft = true; // force a reload of the draft in onResume - } - } - - @Override - public void onDraftAttachmentLimitReached(final DraftMessageData data) { - // no-op for now - } - - @Override - public void onDraftAttachmentLoadFailed() { - // no-op for now - } - - @Override - public int getAttachmentsClearedFlags() { - return DraftMessageData.ATTACHMENTS_CHANGED; - } -} diff --git a/src/com/android/messaging/ui/conversation/ConversationInput.java b/src/com/android/messaging/ui/conversation/ConversationInput.java deleted file mode 100644 index cf98dbed..00000000 --- a/src/com/android/messaging/ui/conversation/ConversationInput.java +++ /dev/null @@ -1,103 +0,0 @@ -/* - * Copyright (C) 2015 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.android.messaging.ui.conversation; - -import android.os.Bundle; -import androidx.appcompat.app.ActionBar; - -/** - * The base class for a method of user input, e.g. media picker. - */ -public abstract class ConversationInput { - /** - * The host component where all input components are contained. This is typically the - * conversation fragment but may be mocked in test code. - */ - public interface ConversationInputBase { - boolean showHideInternal(final ConversationInput target, final boolean show, - final boolean animate); - String getInputStateKey(final ConversationInput input); - void beginUpdate(); - void handleOnShow(final ConversationInput target); - void endUpdate(); - } - - protected boolean mShowing; - protected ConversationInputBase mConversationInputBase; - - public abstract boolean show(boolean animate); - public abstract boolean hide(boolean animate); - - public ConversationInput(ConversationInputBase baseHost, final boolean isShowing) { - mConversationInputBase = baseHost; - mShowing = isShowing; - } - - public boolean onBackPressed() { - if (mShowing) { - mConversationInputBase.showHideInternal(this, false /* show */, true /* animate */); - return true; - } - return false; - } - - public boolean onNavigationUpPressed() { - return false; - } - - /** - * Toggle the visibility of this view. - * @param animate - * @return true if the view is now shown, false if it now hidden - */ - public boolean toggle(final boolean animate) { - mConversationInputBase.showHideInternal(this, !mShowing /* show */, true /* animate */); - return mShowing; - } - - public void saveState(final Bundle savedState) { - savedState.putBoolean(mConversationInputBase.getInputStateKey(this), mShowing); - } - - public void restoreState(final Bundle savedState) { - // Things are hidden by default, so only handle show. - if (savedState.getBoolean(mConversationInputBase.getInputStateKey(this))) { - mConversationInputBase.showHideInternal(this, true /* show */, false /* animate */); - } - } - - public boolean updateActionBar(final ActionBar actionBar) { - return false; - } - - /** - * Update our visibility flag in response to visibility change, both for actions - * initiated by this class (through the show/hide methods), and for external changes - * tracked by event listeners (e.g. ImeStateObserver, MediaPickerListener). As part of - * handling an input showing, we will hide all other inputs to ensure they are mutually - * exclusive. - */ - protected void onVisibilityChanged(final boolean visible) { - if (mShowing != visible) { - mConversationInputBase.beginUpdate(); - mShowing = visible; - if (visible) { - mConversationInputBase.handleOnShow(this); - } - mConversationInputBase.endUpdate(); - } - } -} diff --git a/src/com/android/messaging/ui/conversation/ConversationInputManager.java b/src/com/android/messaging/ui/conversation/ConversationInputManager.java deleted file mode 100644 index bacafc7c..00000000 --- a/src/com/android/messaging/ui/conversation/ConversationInputManager.java +++ /dev/null @@ -1,551 +0,0 @@ -/* - * Copyright (C) 2015 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.android.messaging.ui.conversation; - -import android.content.Context; -import android.os.Bundle; -import androidx.appcompat.app.ActionBar; -import androidx.fragment.app.FragmentManager; - -import android.widget.EditText; - -import com.android.messaging.R; -import com.android.messaging.datamodel.binding.BindingBase; -import com.android.messaging.datamodel.binding.ImmutableBindingRef; -import com.android.messaging.datamodel.data.ConversationData; -import com.android.messaging.datamodel.data.ConversationData.ConversationDataListener; -import com.android.messaging.datamodel.data.ConversationData.SimpleConversationDataListener; -import com.android.messaging.datamodel.data.DraftMessageData; -import com.android.messaging.datamodel.data.DraftMessageData.DraftMessageSubscriptionDataProvider; -import com.android.messaging.datamodel.data.MessagePartData; -import com.android.messaging.datamodel.data.PendingAttachmentData; -import com.android.messaging.datamodel.data.SubscriptionListData.SubscriptionListEntry; -import com.android.messaging.ui.ConversationDrawables; -import com.android.messaging.ui.mediapicker.MediaPicker; -import com.android.messaging.ui.mediapicker.MediaPicker.MediaPickerListener; -import com.android.messaging.util.Assert; -import com.android.messaging.util.ImeUtil; -import com.android.messaging.util.ImeUtil.ImeStateHost; -import com.google.common.annotations.VisibleForTesting; - -import java.util.Collection; - -/** - * Manages showing/hiding/persisting different mutually exclusive UI components nested in - * ConversationFragment that take user inputs, i.e. media picker, SIM selector and - * IME keyboard (the IME keyboard is not owned by Bugle, but we try to model it the same way - * as the other components). - */ -public class ConversationInputManager implements ConversationInput.ConversationInputBase { - /** - * The host component where all input components are contained. This is typically the - * conversation fragment but may be mocked in test code. - */ - public interface ConversationInputHost extends DraftMessageSubscriptionDataProvider { - void invalidateActionBar(); - void setOptionsMenuVisibility(boolean visible); - void dismissActionMode(); - void selectSim(SubscriptionListEntry subscriptionData); - void onStartComposeMessage(); - SimSelectorView getSimSelectorView(); - MediaPicker createMediaPicker(); - void showHideSimSelector(boolean show); - int getSimSelectorItemLayoutId(); - } - - /** - * The "sink" component where all inputs components will direct the user inputs to. This is - * typically the ComposeMessageView but may be mocked in test code. - */ - public interface ConversationInputSink { - void onMediaItemsSelected(Collection items); - void onMediaItemsUnselected(MessagePartData item); - void onPendingAttachmentAdded(PendingAttachmentData pendingItem); - void resumeComposeMessage(); - EditText getComposeEditText(); - void setAccessibility(boolean enabled); - } - - private final ConversationInputHost mHost; - private final ConversationInputSink mSink; - - /** Dependencies injected from the host during construction */ - private final FragmentManager mFragmentManager; - private final Context mContext; - private final ImeStateHost mImeStateHost; - private final ImmutableBindingRef mConversationDataModel; - private final ImmutableBindingRef mDraftDataModel; - - private final ConversationInput[] mInputs; - private final ConversationMediaPicker mMediaInput; - private final ConversationSimSelector mSimInput; - private final ConversationImeKeyboard mImeInput; - private int mUpdateCount; - - private final ImeUtil.ImeStateObserver mImeStateObserver = new ImeUtil.ImeStateObserver() { - @Override - public void onImeStateChanged(final boolean imeOpen) { - mImeInput.onVisibilityChanged(imeOpen); - } - }; - - private final ConversationDataListener mDataListener = new SimpleConversationDataListener() { - @Override - public void onConversationParticipantDataLoaded(ConversationData data) { - mConversationDataModel.ensureBound(data); - } - - @Override - public void onSubscriptionListDataLoaded(ConversationData data) { - mConversationDataModel.ensureBound(data); - mSimInput.onSubscriptionListDataLoaded(data.getSubscriptionListData()); - } - }; - - public ConversationInputManager( - final Context context, - final ConversationInputHost host, - final ConversationInputSink sink, - final ImeStateHost imeStateHost, - final FragmentManager fm, - final BindingBase conversationDataModel, - final BindingBase draftDataModel, - final Bundle savedState) { - mHost = host; - mSink = sink; - mFragmentManager = fm; - mContext = context; - mImeStateHost = imeStateHost; - mConversationDataModel = BindingBase.createBindingReference(conversationDataModel); - mDraftDataModel = BindingBase.createBindingReference(draftDataModel); - - // Register listeners on dependencies. - mImeStateHost.registerImeStateObserver(mImeStateObserver); - mConversationDataModel.getData().addConversationDataListener(mDataListener); - - // Initialize the inputs - mMediaInput = new ConversationMediaPicker(this); - mSimInput = new SimSelector(this); - mImeInput = new ConversationImeKeyboard(this, mImeStateHost.isImeOpen()); - mInputs = new ConversationInput[] { mMediaInput, mSimInput, mImeInput }; - - if (savedState != null) { - for (int i = 0; i < mInputs.length; i++) { - mInputs[i].restoreState(savedState); - } - } - updateHostOptionsMenu(); - } - - public void onDetach() { - mImeStateHost.unregisterImeStateObserver(mImeStateObserver); - // Don't need to explicitly unregister for data model events. It will unregister all - // listeners automagically on unbind. - } - - public void onSaveInputState(final Bundle savedState) { - for (int i = 0; i < mInputs.length; i++) { - mInputs[i].saveState(savedState); - } - } - - @Override - public String getInputStateKey(final ConversationInput input) { - return input.getClass().getCanonicalName() + "_savedstate_"; - } - - public boolean onBackPressed() { - for (int i = 0; i < mInputs.length; i++) { - if (mInputs[i].onBackPressed()) { - return true; - } - } - return false; - } - - public boolean onNavigationUpPressed() { - for (int i = 0; i < mInputs.length; i++) { - if (mInputs[i].onNavigationUpPressed()) { - return true; - } - } - return false; - } - - public void resetMediaPickerState() { - mMediaInput.resetViewHolderState(); - } - - public void showHideMediaPicker(final boolean show, final boolean animate) { - showHideInternal(mMediaInput, show, animate); - } - - /** - * Show or hide the sim selector - * @param show visibility - * @param animate whether to animate the change in visibility - * @return true if the state of the visibility was changed - */ - public boolean showHideSimSelector(final boolean show, final boolean animate) { - return showHideInternal(mSimInput, show, animate); - } - - public void showHideImeKeyboard(final boolean show, final boolean animate) { - showHideInternal(mImeInput, show, animate); - } - - public void hideAllInputs(final boolean animate) { - beginUpdate(); - for (int i = 0; i < mInputs.length; i++) { - showHideInternal(mInputs[i], false, animate); - } - endUpdate(); - } - - /** - * Toggle the visibility of the sim selector. - * @param animate - * @param subEntry - * @return true if the view is now shown, false if it now hidden - */ - public boolean toggleSimSelector(final boolean animate, final SubscriptionListEntry subEntry) { - mSimInput.setSelected(subEntry); - return mSimInput.toggle(animate); - } - - public boolean updateActionBar(final ActionBar actionBar) { - for (int i = 0; i < mInputs.length; i++) { - if (mInputs[i].mShowing) { - return mInputs[i].updateActionBar(actionBar); - } - } - return false; - } - - @VisibleForTesting - boolean isMediaPickerVisible() { - return mMediaInput.mShowing; - } - - @VisibleForTesting - boolean isSimSelectorVisible() { - return mSimInput.mShowing; - } - - @VisibleForTesting - boolean isImeKeyboardVisible() { - return mImeInput.mShowing; - } - - @VisibleForTesting - void testNotifyImeStateChanged(final boolean imeOpen) { - mImeStateObserver.onImeStateChanged(imeOpen); - } - - /** - * returns true if the state of the visibility was actually changed - */ - @Override - public boolean showHideInternal(final ConversationInput target, final boolean show, - final boolean animate) { - if (!mConversationDataModel.isBound()) { - return false; - } - - if (target.mShowing == show) { - return false; - } - beginUpdate(); - boolean success; - if (!show) { - success = target.hide(animate); - } else { - success = target.show(animate); - } - - if (success) { - target.onVisibilityChanged(show); - } - endUpdate(); - return true; - } - - @Override - public void handleOnShow(final ConversationInput target) { - if (!mConversationDataModel.isBound()) { - return; - } - beginUpdate(); - - // All inputs are mutually exclusive. Showing one will hide everything else. - // The one exception, is that the keyboard and location media chooser can be open at the - // time to enable searching within that chooser - for (int i = 0; i < mInputs.length; i++) { - final ConversationInput currInput = mInputs[i]; - if (currInput != target) { - // TODO : If there's more exceptions we will want to make this more - // generic - if (currInput instanceof ConversationMediaPicker && - target instanceof ConversationImeKeyboard && - mMediaInput.getExistingOrCreateMediaPicker() != null && - mMediaInput.getExistingOrCreateMediaPicker().canShowIme()) { - // Allow the keyboard and location mediaPicker to be open at the same time, - // but ensure the media picker is full screen to allow enough room - mMediaInput.getExistingOrCreateMediaPicker().setFullScreen(true); - continue; - } - showHideInternal(currInput, false /* show */, false /* animate */); - } - } - // Always dismiss action mode on show. - mHost.dismissActionMode(); - // Invoking any non-keyboard input UI is treated as starting message compose. - if (target != mImeInput) { - mHost.onStartComposeMessage(); - } - endUpdate(); - } - - @Override - public void beginUpdate() { - mUpdateCount++; - } - - @Override - public void endUpdate() { - Assert.isTrue(mUpdateCount > 0); - if (--mUpdateCount == 0) { - // Always try to update the host action bar after every update cycle. - mHost.invalidateActionBar(); - } - } - - private void updateHostOptionsMenu() { - mHost.setOptionsMenuVisibility(!mMediaInput.isOpen()); - } - - /** - * Manages showing/hiding the media picker in conversation. - */ - private class ConversationMediaPicker extends ConversationInput { - public ConversationMediaPicker(ConversationInputBase baseHost) { - super(baseHost, false); - } - - private MediaPicker mMediaPicker; - - @Override - public boolean show(boolean animate) { - if (mMediaPicker == null) { - mMediaPicker = getExistingOrCreateMediaPicker(); - setConversationThemeColor(ConversationDrawables.get().getConversationThemeColor()); - mMediaPicker.setSubscriptionDataProvider(mHost); - mMediaPicker.setDraftMessageDataModel(mDraftDataModel); - mMediaPicker.setListener(new MediaPickerListener() { - @Override - public void onOpened() { - handleStateChange(); - } - - @Override - public void onFullScreenChanged(boolean fullScreen) { - // When we're full screen, we want to disable accessibility on the - // ComposeMessageView controls (attach button, message input, sim chooser) - // that are hiding underneath the action bar. - mSink.setAccessibility(!fullScreen /*enabled*/); - handleStateChange(); - } - - @Override - public void onDismissed() { - // Re-enable accessibility on all controls now that the media picker is - // going away. - mSink.setAccessibility(true /*enabled*/); - handleStateChange(); - } - - private void handleStateChange() { - onVisibilityChanged(isOpen()); - mHost.invalidateActionBar(); - updateHostOptionsMenu(); - } - - @Override - public void onItemsSelected(final Collection items, - final boolean resumeCompose) { - mSink.onMediaItemsSelected(items); - mHost.invalidateActionBar(); - if (resumeCompose) { - mSink.resumeComposeMessage(); - } - } - - @Override - public void onItemUnselected(final MessagePartData item) { - mSink.onMediaItemsUnselected(item); - mHost.invalidateActionBar(); - } - - @Override - public void onConfirmItemSelection() { - mSink.resumeComposeMessage(); - } - - @Override - public void onPendingItemAdded(final PendingAttachmentData pendingItem) { - mSink.onPendingAttachmentAdded(pendingItem); - } - - @Override - public void onChooserSelected(final int chooserIndex) { - mHost.invalidateActionBar(); - mHost.dismissActionMode(); - } - }); - } - - mMediaPicker.open(MediaPicker.MEDIA_TYPE_DEFAULT, animate); - - return isOpen(); - } - - @Override - public boolean hide(boolean animate) { - if (mMediaPicker != null) { - mMediaPicker.dismiss(animate); - } - return !isOpen(); - } - - public void resetViewHolderState() { - if (mMediaPicker != null) { - mMediaPicker.resetViewHolderState(); - } - } - - public void setConversationThemeColor(final int themeColor) { - if (mMediaPicker != null) { - mMediaPicker.setConversationThemeColor(themeColor); - } - } - - private boolean isOpen() { - return (mMediaPicker != null && mMediaPicker.isOpen()); - } - - private MediaPicker getExistingOrCreateMediaPicker() { - if (mMediaPicker != null) { - return mMediaPicker; - } - MediaPicker mediaPicker = (MediaPicker) - mFragmentManager.findFragmentByTag(MediaPicker.FRAGMENT_TAG); - if (mediaPicker == null) { - mediaPicker = mHost.createMediaPicker(); - if (mediaPicker == null) { - return null; // this use of ComposeMessageView doesn't support media picking - } - mFragmentManager.beginTransaction().replace( - R.id.mediapicker_container, - mediaPicker, - MediaPicker.FRAGMENT_TAG).commit(); - } - return mediaPicker; - } - - @Override - public boolean updateActionBar(ActionBar actionBar) { - if (isOpen()) { - mMediaPicker.updateActionBar(actionBar); - return true; - } - return false; - } - - @Override - public boolean onNavigationUpPressed() { - if (isOpen() && mMediaPicker.isFullScreen()) { - return onBackPressed(); - } - return super.onNavigationUpPressed(); - } - - public boolean onBackPressed() { - if (mMediaPicker != null && mMediaPicker.onBackPressed()) { - return true; - } - return super.onBackPressed(); - } - } - - /** - * Manages showing/hiding the SIM selector in conversation. - */ - private class SimSelector extends ConversationSimSelector { - public SimSelector(ConversationInputBase baseHost) { - super(baseHost); - } - - @Override - protected SimSelectorView getSimSelectorView() { - return mHost.getSimSelectorView(); - } - - @Override - public int getSimSelectorItemLayoutId() { - return mHost.getSimSelectorItemLayoutId(); - } - - @Override - protected void selectSim(SubscriptionListEntry item) { - mHost.selectSim(item); - } - - @Override - public boolean show(boolean animate) { - final boolean result = super.show(animate); - mHost.showHideSimSelector(true /*show*/); - return result; - } - - @Override - public boolean hide(boolean animate) { - final boolean result = super.hide(animate); - mHost.showHideSimSelector(false /*show*/); - return result; - } - } - - /** - * Manages showing/hiding the IME keyboard in conversation. - */ - private class ConversationImeKeyboard extends ConversationInput { - public ConversationImeKeyboard(ConversationInputBase baseHost, final boolean isShowing) { - super(baseHost, isShowing); - } - - @Override - public boolean show(boolean animate) { - ImeUtil.get().showImeKeyboard(mContext, mSink.getComposeEditText()); - return true; - } - - @Override - public boolean hide(boolean animate) { - ImeUtil.get().hideImeKeyboard(mContext, mSink.getComposeEditText()); - return true; - } - } -} diff --git a/src/com/android/messaging/ui/conversation/ConversationMessageAdapter.java b/src/com/android/messaging/ui/conversation/ConversationMessageAdapter.java deleted file mode 100644 index fb04bb40..00000000 --- a/src/com/android/messaging/ui/conversation/ConversationMessageAdapter.java +++ /dev/null @@ -1,117 +0,0 @@ -/* - * Copyright (C) 2015 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.android.messaging.ui.conversation; - -import android.content.Context; -import android.database.Cursor; -import androidx.recyclerview.widget.RecyclerView; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; - -import com.android.messaging.R; -import com.android.messaging.ui.AsyncImageView; -import com.android.messaging.ui.CursorRecyclerAdapter; -import com.android.messaging.ui.AsyncImageView.AsyncImageViewDelayLoader; -import com.android.messaging.ui.conversation.ConversationMessageView.ConversationMessageViewHost; -import com.android.messaging.util.Assert; - -import java.util.HashSet; -import java.util.List; - -/** - * Provides an interface to expose Conversation Message Cursor data to a UI widget like a - * RecyclerView. - */ -public class ConversationMessageAdapter extends - CursorRecyclerAdapter { - - private final ConversationMessageViewHost mHost; - private final AsyncImageViewDelayLoader mImageViewDelayLoader; - private final View.OnClickListener mViewClickListener; - private final View.OnLongClickListener mViewLongClickListener; - private boolean mOneOnOne; - private String mSelectedMessageId; - - public ConversationMessageAdapter(final Context context, final Cursor cursor, - final ConversationMessageViewHost host, - final AsyncImageViewDelayLoader imageViewDelayLoader, - final View.OnClickListener viewClickListener, - final View.OnLongClickListener longClickListener) { - super(context, cursor, 0); - mHost = host; - mViewClickListener = viewClickListener; - mViewLongClickListener = longClickListener; - mImageViewDelayLoader = imageViewDelayLoader; - setHasStableIds(true); - } - - @Override - public void bindViewHolder(final ConversationMessageViewHolder holder, - final Context context, final Cursor cursor) { - Assert.isTrue(holder.mView instanceof ConversationMessageView); - final ConversationMessageView conversationMessageView = - (ConversationMessageView) holder.mView; - conversationMessageView.bind(cursor, mOneOnOne, mSelectedMessageId); - } - - @Override - public ConversationMessageViewHolder createViewHolder(final Context context, - final ViewGroup parent, final int viewType) { - final LayoutInflater layoutInflater = LayoutInflater.from(context); - final ConversationMessageView conversationMessageView = (ConversationMessageView) - layoutInflater.inflate(R.layout.conversation_message_view, null); - conversationMessageView.setHost(mHost); - conversationMessageView.setImageViewDelayLoader(mImageViewDelayLoader); - return new ConversationMessageViewHolder(conversationMessageView, - mViewClickListener, mViewLongClickListener); - } - - public void setSelectedMessage(final String messageId) { - mSelectedMessageId = messageId; - notifyDataSetChanged(); - } - - public void setOneOnOne(final boolean oneOnOne, final boolean invalidate) { - if (mOneOnOne != oneOnOne) { - mOneOnOne = oneOnOne; - if (invalidate) { - notifyDataSetChanged(); - } - } - } - - /** - * ViewHolder that holds a ConversationMessageView. - */ - public static class ConversationMessageViewHolder extends RecyclerView.ViewHolder { - final View mView; - - /** - * @param viewClickListener a View.OnClickListener that should define the interaction when - * an item in the RecyclerView is clicked. - */ - public ConversationMessageViewHolder(final View itemView, - final View.OnClickListener viewClickListener, - final View.OnLongClickListener viewLongClickListener) { - super(itemView); - mView = itemView; - - mView.setOnClickListener(viewClickListener); - mView.setOnLongClickListener(viewLongClickListener); - } - } -} diff --git a/src/com/android/messaging/ui/conversation/ConversationMessageBubbleView.java b/src/com/android/messaging/ui/conversation/ConversationMessageBubbleView.java deleted file mode 100644 index ef6aeb4a..00000000 --- a/src/com/android/messaging/ui/conversation/ConversationMessageBubbleView.java +++ /dev/null @@ -1,132 +0,0 @@ -/* - * Copyright (C) 2015 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.android.messaging.ui.conversation; - -import android.animation.Animator; -import android.animation.Animator.AnimatorListener; -import android.animation.ObjectAnimator; -import android.content.Context; -import android.util.AttributeSet; -import android.view.ViewGroup; -import android.widget.LinearLayout; - -import com.android.messaging.R; -import com.android.messaging.annotation.VisibleForAnimation; -import com.android.messaging.datamodel.data.ConversationMessageBubbleData; -import com.android.messaging.datamodel.data.ConversationMessageData; -import com.android.messaging.util.UiUtils; - -/** - * Shows the message bubble for one conversation message. It is able to animate size changes - * by morphing when the message content changes size. - */ -// TODO: Move functionality from ConversationMessageView into this class as appropriate -public class ConversationMessageBubbleView extends LinearLayout { - private int mIntrinsicWidth; - private int mMorphedWidth; - private ObjectAnimator mAnimator; - private boolean mShouldAnimateWidthChange; - private final ConversationMessageBubbleData mData; - private int mRunningStartWidth; - private ViewGroup mBubbleBackground; - - public ConversationMessageBubbleView(final Context context, final AttributeSet attrs) { - super(context, attrs); - mData = new ConversationMessageBubbleData(); - } - - @Override - protected void onFinishInflate() { - super.onFinishInflate(); - mBubbleBackground = (ViewGroup) findViewById(R.id.message_text_and_info); - } - - @Override - protected void onMeasure(final int widthMeasureSpec, final int heightMeasureSpec) { - super.onMeasure(widthMeasureSpec, heightMeasureSpec); - - final int newIntrinsicWidth = getMeasuredWidth(); - if (mIntrinsicWidth == 0 && newIntrinsicWidth != mIntrinsicWidth) { - if (mShouldAnimateWidthChange) { - kickOffMorphAnimation(mIntrinsicWidth, newIntrinsicWidth); - } - mIntrinsicWidth = newIntrinsicWidth; - } - - if (mMorphedWidth > 0) { - mBubbleBackground.getLayoutParams().width = mMorphedWidth; - } else { - mBubbleBackground.getLayoutParams().width = LayoutParams.WRAP_CONTENT; - } - mBubbleBackground.requestLayout(); - } - - @VisibleForAnimation - public void setMorphWidth(final int width) { - mMorphedWidth = width; - requestLayout(); - } - - public void bind(final ConversationMessageData data) { - final boolean changed = mData.bind(data); - // Animate width change only when we are binding to the same message, so that we may - // animate view size changes on the same message bubble due to things like status text - // change. - // Don't animate width change when the bubble contains attachments. Width animation is - // only suitable for text-only messages (where the bubble size change due to status or - // time stamp changes). - mShouldAnimateWidthChange = !changed && !data.hasAttachments(); - if (mAnimator == null) { - mMorphedWidth = 0; - } - } - - public void kickOffMorphAnimation(final int oldWidth, final int newWidth) { - if (mAnimator != null) { - mAnimator.setIntValues(mRunningStartWidth, newWidth); - return; - } - mRunningStartWidth = oldWidth; - mAnimator = ObjectAnimator.ofInt(this, "morphWidth", oldWidth, newWidth); - mAnimator.setDuration(UiUtils.MEDIAPICKER_TRANSITION_DURATION); - mAnimator.addListener(new AnimatorListener() { - @Override - public void onAnimationStart(Animator animator) { - } - - @Override - public void onAnimationEnd(Animator animator) { - mAnimator = null; - mMorphedWidth = 0; - // Allow the bubble to resize if, for example, the status text changed during - // the animation. This will snap to the bigger size if needed. This is intentional - // as animating immediately after looks really bad and switching layout params - // during the original animation does not achieve the desired effect. - mBubbleBackground.getLayoutParams().width = LayoutParams.WRAP_CONTENT; - mBubbleBackground.requestLayout(); - } - - @Override - public void onAnimationCancel(Animator animator) { - } - - @Override - public void onAnimationRepeat(Animator animator) { - } - }); - mAnimator.start(); - } -} diff --git a/src/com/android/messaging/ui/conversation/ConversationMessageView.java b/src/com/android/messaging/ui/conversation/ConversationMessageView.java deleted file mode 100644 index 4cc11f2f..00000000 --- a/src/com/android/messaging/ui/conversation/ConversationMessageView.java +++ /dev/null @@ -1,1195 +0,0 @@ -/* - * Copyright (C) 2015 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.android.messaging.ui.conversation; - -import android.content.Context; -import android.content.res.Resources; -import android.database.Cursor; -import android.graphics.Rect; -import android.graphics.drawable.Drawable; -import android.net.Uri; -import android.text.Spanned; -import android.text.TextUtils; -import android.text.format.DateUtils; -import android.text.format.Formatter; -import android.text.style.URLSpan; -import android.text.util.Linkify; -import android.util.AttributeSet; -import android.util.DisplayMetrics; -import android.view.Gravity; -import android.view.LayoutInflater; -import android.view.MotionEvent; -import android.view.View; -import android.view.ViewGroup; -import android.view.WindowManager; -import android.widget.FrameLayout; -import android.widget.ImageView.ScaleType; -import android.widget.LinearLayout; -import android.widget.TextView; - -import com.android.messaging.R; -import com.android.messaging.datamodel.DataModel; -import com.android.messaging.datamodel.data.ConversationMessageData; -import com.android.messaging.datamodel.data.MessageData; -import com.android.messaging.datamodel.data.MessagePartData; -import com.android.messaging.datamodel.data.SubscriptionListData.SubscriptionListEntry; -import com.android.messaging.datamodel.media.ImageRequestDescriptor; -import com.android.messaging.datamodel.media.MessagePartImageRequestDescriptor; -import com.android.messaging.datamodel.media.UriImageRequestDescriptor; -import com.android.messaging.sms.MmsUtils; -import com.android.messaging.ui.AsyncImageView; -import com.android.messaging.ui.AsyncImageView.AsyncImageViewDelayLoader; -import com.android.messaging.ui.AudioAttachmentView; -import com.android.messaging.ui.ContactIconView; -import com.android.messaging.ui.ConversationDrawables; -import com.android.messaging.ui.MultiAttachmentLayout; -import com.android.messaging.ui.MultiAttachmentLayout.OnAttachmentClickListener; -import com.android.messaging.ui.PersonItemView; -import com.android.messaging.ui.UIIntents; -import com.android.messaging.ui.VideoThumbnailView; -import com.android.messaging.util.AccessibilityUtil; -import com.android.messaging.util.Assert; -import com.android.messaging.util.AvatarUriUtil; -import com.android.messaging.util.ContentType; -import com.android.messaging.util.ImageUtils; -import com.android.messaging.util.OsUtil; -import com.android.messaging.util.PhoneUtils; -import com.android.messaging.util.UiUtils; -import com.android.messaging.util.YouTubeUtil; - -import java.util.Collections; -import java.util.Comparator; -import java.util.List; -import java.util.function.Predicate; - -import androidx.annotation.Nullable; - -/** - * The view for a single entry in a conversation. - */ -public class ConversationMessageView extends FrameLayout implements View.OnClickListener, - View.OnLongClickListener, OnAttachmentClickListener { - public interface ConversationMessageViewHost { - boolean onAttachmentClick(ConversationMessageView view, MessagePartData attachment, - Rect imageBounds, boolean longPress); - SubscriptionListEntry getSubscriptionEntryForSelfParticipant(String selfParticipantId, - boolean excludeDefault); - } - - private final ConversationMessageData mData; - - private LinearLayout mMessageAttachmentsView; - private MultiAttachmentLayout mMultiAttachmentView; - private AsyncImageView mMessageImageView; - private TextView mMessageTextView; - private boolean mMessageTextHasLinks; - private boolean mMessageHasYouTubeLink; - private TextView mStatusTextView; - private TextView mTitleTextView; - private TextView mMmsInfoTextView; - private LinearLayout mMessageTitleLayout; - private TextView mSenderNameTextView; - private ContactIconView mContactIconView; - private ConversationMessageBubbleView mMessageBubble; - private View mSubjectView; - private TextView mSubjectLabel; - private TextView mSubjectText; - private View mDeliveredBadge; - private ViewGroup mMessageMetadataView; - private ViewGroup mMessageTextAndInfoView; - private TextView mSimNameView; - - private boolean mOneOnOne; - private ConversationMessageViewHost mHost; - - public ConversationMessageView(final Context context, final AttributeSet attrs) { - super(context, attrs); - // TODO: we should switch to using Binding and DataModel factory methods. - mData = new ConversationMessageData(); - } - - @Override - protected void onFinishInflate() { - mContactIconView = (ContactIconView) findViewById(R.id.conversation_icon); - mContactIconView.setOnLongClickListener(new OnLongClickListener() { - @Override - public boolean onLongClick(final View view) { - ConversationMessageView.this.performLongClick(); - return true; - } - }); - - mMessageAttachmentsView = (LinearLayout) findViewById(R.id.message_attachments); - mMultiAttachmentView = (MultiAttachmentLayout) findViewById(R.id.multiple_attachments); - mMultiAttachmentView.setOnAttachmentClickListener(this); - - mMessageImageView = (AsyncImageView) findViewById(R.id.message_image); - mMessageImageView.setOnClickListener(this); - mMessageImageView.setOnLongClickListener(this); - - mMessageTextView = (TextView) findViewById(R.id.message_text); - mMessageTextView.setOnClickListener(this); - IgnoreLinkLongClickHelper.ignoreLinkLongClick(mMessageTextView, this); - - mStatusTextView = (TextView) findViewById(R.id.message_status); - mTitleTextView = (TextView) findViewById(R.id.message_title); - mMmsInfoTextView = (TextView) findViewById(R.id.mms_info); - mMessageTitleLayout = (LinearLayout) findViewById(R.id.message_title_layout); - mSenderNameTextView = (TextView) findViewById(R.id.message_sender_name); - mMessageBubble = (ConversationMessageBubbleView) findViewById(R.id.message_content); - mSubjectView = findViewById(R.id.subject_container); - mSubjectLabel = (TextView) mSubjectView.findViewById(R.id.subject_label); - mSubjectText = (TextView) mSubjectView.findViewById(R.id.subject_text); - mDeliveredBadge = findViewById(R.id.smsDeliveredBadge); - mMessageMetadataView = (ViewGroup) findViewById(R.id.message_metadata); - mMessageTextAndInfoView = (ViewGroup) findViewById(R.id.message_text_and_info); - mSimNameView = (TextView) findViewById(R.id.sim_name); - } - - @Override - protected void onMeasure(final int widthMeasureSpec, final int heightMeasureSpec) { - final int horizontalSpace = MeasureSpec.getSize(widthMeasureSpec); - final int iconSize = getResources() - .getDimensionPixelSize(R.dimen.conversation_message_contact_icon_size); - - final int unspecifiedMeasureSpec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED); - final int iconMeasureSpec = MeasureSpec.makeMeasureSpec(iconSize, MeasureSpec.EXACTLY); - - mContactIconView.measure(iconMeasureSpec, iconMeasureSpec); - - final int arrowWidth = - getResources().getDimensionPixelSize(R.dimen.message_bubble_arrow_width); - - // We need to subtract contact icon width twice from the horizontal space to get - // the max leftover space because we want the message bubble to extend no further than the - // starting position of the message bubble in the opposite direction. - final int maxLeftoverSpace = horizontalSpace - mContactIconView.getMeasuredWidth() * 2 - - arrowWidth - getPaddingLeft() - getPaddingRight(); - final int messageContentWidthMeasureSpec = MeasureSpec.makeMeasureSpec(maxLeftoverSpace, - MeasureSpec.AT_MOST); - - mMessageBubble.measure(messageContentWidthMeasureSpec, unspecifiedMeasureSpec); - - final int maxHeight = Math.max(mContactIconView.getMeasuredHeight(), - mMessageBubble.getMeasuredHeight()); - setMeasuredDimension(horizontalSpace, maxHeight + getPaddingBottom() + getPaddingTop()); - } - - @Override - protected void onLayout(final boolean changed, final int left, final int top, final int right, - final int bottom) { - final boolean isRtl = AccessibilityUtil.isLayoutRtl(this); - - final int iconWidth = mContactIconView.getMeasuredWidth(); - final int iconHeight = mContactIconView.getMeasuredHeight(); - final int iconTop = getPaddingTop(); - final int contentWidth = (right -left) - iconWidth - getPaddingLeft() - getPaddingRight(); - final int contentHeight = mMessageBubble.getMeasuredHeight(); - final int contentTop = iconTop; - - final int iconLeft; - final int contentLeft; - if (mData.getIsIncoming()) { - if (isRtl) { - iconLeft = (right - left) - getPaddingRight() - iconWidth; - contentLeft = iconLeft - contentWidth; - } else { - iconLeft = getPaddingLeft(); - contentLeft = iconLeft + iconWidth; - } - } else { - if (isRtl) { - iconLeft = getPaddingLeft(); - contentLeft = iconLeft + iconWidth; - } else { - iconLeft = (right - left) - getPaddingRight() - iconWidth; - contentLeft = iconLeft - contentWidth; - } - } - - mContactIconView.layout(iconLeft, iconTop, iconLeft + iconWidth, iconTop + iconHeight); - - mMessageBubble.layout(contentLeft, contentTop, contentLeft + contentWidth, - contentTop + contentHeight); - } - - /** - * Fills in the data associated with this view. - * - * @param cursor The cursor from a MessageList that this view is in, pointing to its entry. - */ - public void bind(final Cursor cursor) { - bind(cursor, true, null); - } - - /** - * Fills in the data associated with this view. - * - * @param cursor The cursor from a MessageList that this view is in, pointing to its entry. - * @param oneOnOne Whether this is a 1:1 conversation - */ - public void bind(final Cursor cursor, - final boolean oneOnOne, final String selectedMessageId) { - mOneOnOne = oneOnOne; - - // Update our UI model - mData.bind(cursor); - setSelected(TextUtils.equals(mData.getMessageId(), selectedMessageId)); - - // Update text and image content for the view. - updateViewContent(); - - // Update colors and layout parameters for the view. - updateViewAppearance(); - - updateContentDescription(); - } - - public void setHost(final ConversationMessageViewHost host) { - mHost = host; - } - - /** - * Sets a delay loader instance to manage loading / resuming of image attachments. - */ - public void setImageViewDelayLoader(final AsyncImageViewDelayLoader delayLoader) { - Assert.notNull(mMessageImageView); - mMessageImageView.setDelayLoader(delayLoader); - mMultiAttachmentView.setImageViewDelayLoader(delayLoader); - } - - public ConversationMessageData getData() { - return mData; - } - - /** - * Returns whether we should show simplified visual style for the message view (i.e. hide the - * avatar and bubble arrow, reduce padding). - */ - private boolean shouldShowSimplifiedVisualStyle() { - return mData.getCanClusterWithPreviousMessage(); - } - - /** - * Returns whether we need to show message bubble arrow. We don't show arrow if the message - * contains media attachments or if shouldShowSimplifiedVisualStyle() is true. - */ - private boolean shouldShowMessageBubbleArrow() { - return !shouldShowSimplifiedVisualStyle() - && !(mData.hasAttachments() || mMessageHasYouTubeLink); - } - - /** - * Returns whether we need to show a message bubble for text content. - */ - private boolean shouldShowMessageTextBubble() { - if (mData.hasText()) { - return true; - } - final String subjectText = MmsUtils.cleanseMmsSubject(getResources(), - mData.getMmsSubject()); - if (!TextUtils.isEmpty(subjectText)) { - return true; - } - return false; - } - - private void updateViewContent() { - updateMessageContent(); - int titleResId = -1; - int statusResId = -1; - String statusText = null; - switch(mData.getStatus()) { - case MessageData.BUGLE_STATUS_INCOMING_AUTO_DOWNLOADING: - case MessageData.BUGLE_STATUS_INCOMING_MANUAL_DOWNLOADING: - case MessageData.BUGLE_STATUS_INCOMING_RETRYING_AUTO_DOWNLOAD: - case MessageData.BUGLE_STATUS_INCOMING_RETRYING_MANUAL_DOWNLOAD: - titleResId = R.string.message_title_downloading; - statusResId = R.string.message_status_downloading; - break; - - case MessageData.BUGLE_STATUS_INCOMING_YET_TO_MANUAL_DOWNLOAD: - if (!OsUtil.isSecondaryUser()) { - titleResId = R.string.message_title_manual_download; - if (isSelected()) { - statusResId = R.string.message_status_download_action; - } else { - statusResId = R.string.message_status_download; - } - } - break; - - case MessageData.BUGLE_STATUS_INCOMING_EXPIRED_OR_NOT_AVAILABLE: - if (!OsUtil.isSecondaryUser()) { - titleResId = R.string.message_title_download_failed; - statusResId = R.string.message_status_download_error; - } - break; - - case MessageData.BUGLE_STATUS_INCOMING_DOWNLOAD_FAILED: - if (!OsUtil.isSecondaryUser()) { - titleResId = R.string.message_title_download_failed; - if (isSelected()) { - statusResId = R.string.message_status_download_action; - } else { - statusResId = R.string.message_status_download; - } - } - break; - - case MessageData.BUGLE_STATUS_OUTGOING_YET_TO_SEND: - case MessageData.BUGLE_STATUS_OUTGOING_SENDING: - statusResId = R.string.message_status_sending; - break; - - case MessageData.BUGLE_STATUS_OUTGOING_RESENDING: - case MessageData.BUGLE_STATUS_OUTGOING_AWAITING_RETRY: - statusResId = R.string.message_status_send_retrying; - break; - - case MessageData.BUGLE_STATUS_OUTGOING_FAILED_EMERGENCY_NUMBER: - statusResId = R.string.message_status_send_failed_emergency_number; - break; - - case MessageData.BUGLE_STATUS_OUTGOING_FAILED: - // don't show the error state unless we're the default sms app - if (PhoneUtils.getDefault().isDefaultSmsApp()) { - if (isSelected()) { - statusResId = R.string.message_status_resend; - } else { - statusResId = MmsUtils.mapRawStatusToErrorResourceId( - mData.getStatus(), mData.getRawTelephonyStatus()); - } - break; - } - // FALL THROUGH HERE - - case MessageData.BUGLE_STATUS_OUTGOING_COMPLETE: - case MessageData.BUGLE_STATUS_OUTGOING_DELIVERED: - case MessageData.BUGLE_STATUS_INCOMING_COMPLETE: - default: - if (!mData.getCanClusterWithNextMessage()) { - statusText = mData.getFormattedReceivedTimeStamp(); - } - break; - } - - final boolean titleVisible = (titleResId >= 0); - if (titleVisible) { - final String titleText = getResources().getString(titleResId); - mTitleTextView.setText(titleText); - - final String mmsInfoText = getResources().getString( - R.string.mms_info, - Formatter.formatFileSize(getContext(), mData.getSmsMessageSize()), - DateUtils.formatDateTime( - getContext(), - mData.getMmsExpiry(), - DateUtils.FORMAT_SHOW_DATE | - DateUtils.FORMAT_SHOW_TIME | - DateUtils.FORMAT_NUMERIC_DATE | - DateUtils.FORMAT_NO_YEAR)); - mMmsInfoTextView.setText(mmsInfoText); - mMessageTitleLayout.setVisibility(View.VISIBLE); - } else { - mMessageTitleLayout.setVisibility(View.GONE); - } - - final String subjectText = MmsUtils.cleanseMmsSubject(getResources(), - mData.getMmsSubject()); - final boolean subjectVisible = !TextUtils.isEmpty(subjectText); - - final boolean senderNameVisible = !mOneOnOne && !mData.getCanClusterWithNextMessage() - && mData.getIsIncoming(); - if (senderNameVisible) { - mSenderNameTextView.setText(mData.getSenderDisplayName()); - mSenderNameTextView.setVisibility(View.VISIBLE); - } else { - mSenderNameTextView.setVisibility(View.GONE); - } - - if (statusResId >= 0) { - statusText = getResources().getString(statusResId); - } - - // We set the text even if the view will be GONE for accessibility - mStatusTextView.setText(statusText); - final boolean statusVisible = !TextUtils.isEmpty(statusText); - if (statusVisible) { - mStatusTextView.setVisibility(View.VISIBLE); - } else { - mStatusTextView.setVisibility(View.GONE); - } - - final boolean deliveredBadgeVisible = - mData.getStatus() == MessageData.BUGLE_STATUS_OUTGOING_DELIVERED; - mDeliveredBadge.setVisibility(deliveredBadgeVisible ? View.VISIBLE : View.GONE); - - // Update the sim indicator. - final boolean showSimIconAsIncoming = mData.getIsIncoming() && - (!mData.hasAttachments() || shouldShowMessageTextBubble()); - final SubscriptionListEntry subscriptionEntry = - mHost.getSubscriptionEntryForSelfParticipant(mData.getSelfParticipantId(), - true /* excludeDefault */); - final boolean simNameVisible = subscriptionEntry != null && - !TextUtils.isEmpty(subscriptionEntry.displayName) && - !mData.getCanClusterWithNextMessage(); - if (simNameVisible) { - final String simNameText = mData.getIsIncoming() ? getResources().getString( - R.string.incoming_sim_name_text, subscriptionEntry.displayName) : - subscriptionEntry.displayName; - mSimNameView.setText(simNameText); - mSimNameView.setTextColor(showSimIconAsIncoming ? getResources().getColor( - R.color.timestamp_text_incoming) : subscriptionEntry.displayColor); - mSimNameView.setVisibility(VISIBLE); - } else { - mSimNameView.setText(null); - mSimNameView.setVisibility(GONE); - } - - final boolean metadataVisible = senderNameVisible || statusVisible - || deliveredBadgeVisible || simNameVisible; - mMessageMetadataView.setVisibility(metadataVisible ? View.VISIBLE : View.GONE); - - final boolean messageTextAndOrInfoVisible = titleVisible || subjectVisible - || mData.hasText() || metadataVisible; - mMessageTextAndInfoView.setVisibility( - messageTextAndOrInfoVisible ? View.VISIBLE : View.GONE); - - if (shouldShowSimplifiedVisualStyle()) { - mContactIconView.setVisibility(View.GONE); - mContactIconView.setImageResourceUri(null); - } else { - mContactIconView.setVisibility(View.VISIBLE); - final Uri avatarUri = AvatarUriUtil.createAvatarUri( - mData.getSenderProfilePhotoUri(), - mData.getSenderFullName(), - mData.getSenderNormalizedDestination(), - mData.getSenderContactLookupKey()); - mContactIconView.setImageResourceUri(avatarUri, mData.getSenderContactId(), - mData.getSenderContactLookupKey(), mData.getSenderNormalizedDestination()); - } - } - - private void updateMessageContent() { - // We must update the text before the attachments since we search the text to see if we - // should make a preview youtube image in the attachments - updateMessageText(); - updateMessageAttachments(); - updateMessageSubject(); - mMessageBubble.bind(mData); - } - - private void updateMessageAttachments() { - // Bind video, audio, and VCard attachments. If there are multiple, they stack vertically. - bindAttachmentsOfSameType(sVideoFilter, - R.layout.message_video_attachment, mVideoViewBinder, VideoThumbnailView.class); - bindAttachmentsOfSameType(sAudioFilter, - R.layout.message_audio_attachment, mAudioViewBinder, AudioAttachmentView.class); - bindAttachmentsOfSameType(sVCardFilter, - R.layout.message_vcard_attachment, mVCardViewBinder, PersonItemView.class); - - // Bind image attachments. If there are multiple, they are shown in a collage view. - final List imageParts = mData.getAttachments(sImageFilter); - if (imageParts.size() > 1) { - Collections.sort(imageParts, sImageComparator); - mMultiAttachmentView.bindAttachments(imageParts, null, imageParts.size()); - mMultiAttachmentView.setVisibility(View.VISIBLE); - } else { - mMultiAttachmentView.setVisibility(View.GONE); - } - - // In the case that we have no image attachments and exactly one youtube link in a message - // then we will show a preview. - String youtubeThumbnailUrl = null; - String originalYoutubeLink = null; - if (mMessageTextHasLinks && imageParts.size() == 0) { - CharSequence messageTextWithSpans = mMessageTextView.getText(); - final URLSpan[] spans = ((Spanned) messageTextWithSpans).getSpans(0, - messageTextWithSpans.length(), URLSpan.class); - for (URLSpan span : spans) { - String url = span.getURL(); - String youtubeLinkForUrl = YouTubeUtil.getYoutubePreviewImageLink(url); - if (!TextUtils.isEmpty(youtubeLinkForUrl)) { - if (TextUtils.isEmpty(youtubeThumbnailUrl)) { - // Save the youtube link if we don't already have one - youtubeThumbnailUrl = youtubeLinkForUrl; - originalYoutubeLink = url; - } else { - // We already have a youtube link. This means we have two youtube links so - // we shall show none. - youtubeThumbnailUrl = null; - originalYoutubeLink = null; - break; - } - } - } - } - // We need to keep track if we have a youtube link in the message so that we will not show - // the arrow - mMessageHasYouTubeLink = !TextUtils.isEmpty(youtubeThumbnailUrl); - - // We will show the message image view if there is one attachment or one youtube link - if (imageParts.size() == 1 || mMessageHasYouTubeLink) { - // Get the display metrics for a hint for how large to pull the image data into - final WindowManager windowManager = (WindowManager) getContext(). - getSystemService(Context.WINDOW_SERVICE); - final DisplayMetrics displayMetrics = new DisplayMetrics(); - windowManager.getDefaultDisplay().getMetrics(displayMetrics); - - final int iconSize = getResources() - .getDimensionPixelSize(R.dimen.conversation_message_contact_icon_size); - final int desiredWidth = displayMetrics.widthPixels - iconSize - iconSize; - - if (imageParts.size() == 1) { - final MessagePartData imagePart = imageParts.get(0); - // If the image is big, we want to scale it down to save memory since we're going to - // scale it down to fit into the bubble width. We don't constrain the height. - final ImageRequestDescriptor imageRequest = - new MessagePartImageRequestDescriptor(imagePart, - desiredWidth, - MessagePartData.UNSPECIFIED_SIZE, - false); - adjustImageViewBounds(imagePart); - mMessageImageView.setImageResourceId(imageRequest); - mMessageImageView.setTag(imagePart); - } else { - // Youtube Thumbnail image - final ImageRequestDescriptor imageRequest = - new UriImageRequestDescriptor(Uri.parse(youtubeThumbnailUrl), desiredWidth, - MessagePartData.UNSPECIFIED_SIZE, true /* allowCompression */, - true /* isStatic */, false /* cropToCircle */, - ImageUtils.DEFAULT_CIRCLE_BACKGROUND_COLOR /* circleBackgroundColor */, - ImageUtils.DEFAULT_CIRCLE_STROKE_COLOR /* circleStrokeColor */); - mMessageImageView.setImageResourceId(imageRequest); - mMessageImageView.setTag(originalYoutubeLink); - } - mMessageImageView.setVisibility(View.VISIBLE); - } else { - mMessageImageView.setImageResourceId(null); - mMessageImageView.setVisibility(View.GONE); - } - - // Show the message attachments container if any of its children are visible - boolean attachmentsVisible = false; - for (int i = 0, size = mMessageAttachmentsView.getChildCount(); i < size; i++) { - final View attachmentView = mMessageAttachmentsView.getChildAt(i); - if (attachmentView.getVisibility() == View.VISIBLE) { - attachmentsVisible = true; - break; - } - } - mMessageAttachmentsView.setVisibility(attachmentsVisible ? View.VISIBLE : View.GONE); - } - - private void bindAttachmentsOfSameType(final Predicate attachmentTypeFilter, - final int attachmentViewLayoutRes, final AttachmentViewBinder viewBinder, - final Class attachmentViewClass) { - final LayoutInflater layoutInflater = LayoutInflater.from(getContext()); - - // Iterate through all attachments of a particular type (video, audio, etc). - // Find the first attachment index that matches the given type if possible. - int attachmentViewIndex = -1; - View existingAttachmentView; - do { - existingAttachmentView = mMessageAttachmentsView.getChildAt(++attachmentViewIndex); - } while (existingAttachmentView != null && - !(attachmentViewClass.isInstance(existingAttachmentView))); - - for (final MessagePartData attachment : mData.getAttachments(attachmentTypeFilter)) { - View attachmentView = mMessageAttachmentsView.getChildAt(attachmentViewIndex); - if (!attachmentViewClass.isInstance(attachmentView)) { - attachmentView = layoutInflater.inflate(attachmentViewLayoutRes, - mMessageAttachmentsView, false /* attachToRoot */); - attachmentView.setOnClickListener(this); - attachmentView.setOnLongClickListener(this); - mMessageAttachmentsView.addView(attachmentView, attachmentViewIndex); - } - viewBinder.bindView(attachmentView, attachment); - attachmentView.setTag(attachment); - attachmentView.setVisibility(View.VISIBLE); - attachmentViewIndex++; - } - // If there are unused views left over, unbind or remove them. - while (attachmentViewIndex < mMessageAttachmentsView.getChildCount()) { - final View attachmentView = mMessageAttachmentsView.getChildAt(attachmentViewIndex); - if (attachmentViewClass.isInstance(attachmentView)) { - mMessageAttachmentsView.removeViewAt(attachmentViewIndex); - } else { - // No more views of this type; we're done. - break; - } - } - } - - private void updateMessageSubject() { - final String subjectText = MmsUtils.cleanseMmsSubject(getResources(), - mData.getMmsSubject()); - final boolean subjectVisible = !TextUtils.isEmpty(subjectText); - - if (subjectVisible) { - mSubjectText.setText(subjectText); - mSubjectView.setVisibility(View.VISIBLE); - } else { - mSubjectView.setVisibility(View.GONE); - } - } - - private void updateMessageText() { - final String text = mData.getText(); - if (!TextUtils.isEmpty(text)) { - mMessageTextView.setText(text); - // Linkify phone numbers, web urls, emails, and map addresses to allow users to - // click on them and take the default intent. - mMessageTextHasLinks = Linkify.addLinks(mMessageTextView, Linkify.ALL); - mMessageTextView.setVisibility(View.VISIBLE); - } else { - mMessageTextView.setVisibility(View.GONE); - mMessageTextHasLinks = false; - } - } - - private void updateViewAppearance() { - final Resources res = getResources(); - final ConversationDrawables drawableProvider = ConversationDrawables.get(); - final boolean incoming = mData.getIsIncoming(); - final boolean outgoing = !incoming; - final boolean showArrow = shouldShowMessageBubbleArrow(); - - final int messageTopPaddingClustered = - res.getDimensionPixelSize(R.dimen.message_padding_same_author); - final int messageTopPaddingDefault = - res.getDimensionPixelSize(R.dimen.message_padding_default); - final int arrowWidth = res.getDimensionPixelOffset(R.dimen.message_bubble_arrow_width); - final int messageTextMinHeightDefault = res.getDimensionPixelSize( - R.dimen.conversation_message_contact_icon_size); - final int messageTextLeftRightPadding = res.getDimensionPixelOffset( - R.dimen.message_text_left_right_padding); - final int textTopPaddingDefault = res.getDimensionPixelOffset( - R.dimen.message_text_top_padding); - final int textBottomPaddingDefault = res.getDimensionPixelOffset( - R.dimen.message_text_bottom_padding); - - // These values depend on whether the message has text, attachments, or both. - // We intentionally don't set defaults, so the compiler will tell us if we forget - // to set one of them, or if we set one more than once. - final int contentLeftPadding, contentRightPadding; - final Drawable textBackground; - final int textMinHeight; - final int textTopMargin; - final int textTopPadding, textBottomPadding; - final int textLeftPadding, textRightPadding; - - if (mData.hasAttachments()) { - if (shouldShowMessageTextBubble()) { - // Text and attachment(s) - contentLeftPadding = incoming ? arrowWidth : 0; - contentRightPadding = outgoing ? arrowWidth : 0; - textBackground = drawableProvider.getBubbleDrawable( - isSelected(), - incoming, - false /* needArrow */, - mData.hasIncomingErrorStatus()); - textMinHeight = messageTextMinHeightDefault; - textTopMargin = messageTopPaddingClustered; - textTopPadding = textTopPaddingDefault; - textBottomPadding = textBottomPaddingDefault; - textLeftPadding = messageTextLeftRightPadding; - textRightPadding = messageTextLeftRightPadding; - mMessageTextView.setTextIsSelectable(isSelected()); - } else { - // Attachment(s) only - contentLeftPadding = incoming ? arrowWidth : 0; - contentRightPadding = outgoing ? arrowWidth : 0; - textBackground = null; - textMinHeight = 0; - textTopMargin = 0; - textTopPadding = 0; - textBottomPadding = 0; - textLeftPadding = 0; - textRightPadding = 0; - } - } else { - // Text only - contentLeftPadding = (!showArrow && incoming) ? arrowWidth : 0; - contentRightPadding = (!showArrow && outgoing) ? arrowWidth : 0; - textBackground = drawableProvider.getBubbleDrawable( - isSelected(), - incoming, - shouldShowMessageBubbleArrow(), - mData.hasIncomingErrorStatus()); - textMinHeight = messageTextMinHeightDefault; - textTopMargin = 0; - textTopPadding = textTopPaddingDefault; - textBottomPadding = textBottomPaddingDefault; - mMessageTextView.setTextIsSelectable(isSelected()); - if (showArrow && incoming) { - textLeftPadding = messageTextLeftRightPadding + arrowWidth; - } else { - textLeftPadding = messageTextLeftRightPadding; - } - if (showArrow && outgoing) { - textRightPadding = messageTextLeftRightPadding + arrowWidth; - } else { - textRightPadding = messageTextLeftRightPadding; - } - } - - // These values do not depend on whether the message includes attachments - final int gravity = incoming ? (Gravity.START | Gravity.CENTER_VERTICAL) : - (Gravity.END | Gravity.CENTER_VERTICAL); - final int messageTopPadding = shouldShowSimplifiedVisualStyle() ? - messageTopPaddingClustered : messageTopPaddingDefault; - final int metadataTopPadding = res.getDimensionPixelOffset( - R.dimen.message_metadata_top_padding); - - // Update the message text/info views - ImageUtils.setBackgroundDrawableOnView(mMessageTextAndInfoView, textBackground); - mMessageTextAndInfoView.setMinimumHeight(textMinHeight); - final LinearLayout.LayoutParams textAndInfoLayoutParams = - (LinearLayout.LayoutParams) mMessageTextAndInfoView.getLayoutParams(); - textAndInfoLayoutParams.topMargin = textTopMargin; - - if (UiUtils.isRtlMode()) { - // Need to switch right and left padding in RtL mode - mMessageTextAndInfoView.setPadding(textRightPadding, textTopPadding, textLeftPadding, - textBottomPadding); - mMessageBubble.setPadding(contentRightPadding, 0, contentLeftPadding, 0); - } else { - mMessageTextAndInfoView.setPadding(textLeftPadding, textTopPadding, textRightPadding, - textBottomPadding); - mMessageBubble.setPadding(contentLeftPadding, 0, contentRightPadding, 0); - } - - // Update the message row and message bubble views - setPadding(getPaddingLeft(), messageTopPadding, getPaddingRight(), 0); - mMessageBubble.setGravity(gravity); - updateMessageAttachmentsAppearance(gravity); - - mMessageMetadataView.setPadding(0, metadataTopPadding, 0, 0); - - updateTextAppearance(); - - requestLayout(); - } - - private void updateContentDescription() { - StringBuilder description = new StringBuilder(); - - Resources res = getResources(); - String separator = res.getString(R.string.enumeration_comma); - - // Sender information - boolean hasPlainTextMessage = !(TextUtils.isEmpty(mData.getText()) || - mMessageTextHasLinks); - if (mData.getIsIncoming()) { - int senderResId = hasPlainTextMessage - ? R.string.incoming_text_sender_content_description - : R.string.incoming_sender_content_description; - description.append(res.getString(senderResId, mData.getSenderDisplayName())); - } else { - int senderResId = hasPlainTextMessage - ? R.string.outgoing_text_sender_content_description - : R.string.outgoing_sender_content_description; - description.append(res.getString(senderResId)); - } - - if (mSubjectView.getVisibility() == View.VISIBLE) { - description.append(separator); - description.append(mSubjectText.getText()); - } - - if (mMessageTextView.getVisibility() == View.VISIBLE) { - // If the message has hyperlinks, we will let the user navigate to the text message so - // that the hyperlink can be clicked. Otherwise, the text message does not need to - // be reachable. - if (mMessageTextHasLinks) { - mMessageTextView.setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_YES); - } else { - mMessageTextView.setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_NO); - description.append(separator); - description.append(mMessageTextView.getText()); - } - } - - if (mMessageTitleLayout.getVisibility() == View.VISIBLE) { - description.append(separator); - description.append(mTitleTextView.getText()); - - description.append(separator); - description.append(mMmsInfoTextView.getText()); - } - - if (mStatusTextView.getVisibility() == View.VISIBLE) { - description.append(separator); - description.append(mStatusTextView.getText()); - } - - if (mSimNameView.getVisibility() == View.VISIBLE) { - description.append(separator); - description.append(mSimNameView.getText()); - } - - if (mDeliveredBadge.getVisibility() == View.VISIBLE) { - description.append(separator); - description.append(res.getString(R.string.delivered_status_content_description)); - } - - setContentDescription(description); - } - - private void updateMessageAttachmentsAppearance(final int gravity) { - mMessageAttachmentsView.setGravity(gravity); - - // Tint image/video attachments when selected - final int selectedImageTint = getResources().getColor(R.color.message_image_selected_tint); - if (mMessageImageView.getVisibility() == View.VISIBLE) { - if (isSelected()) { - mMessageImageView.setColorFilter(selectedImageTint); - } else { - mMessageImageView.clearColorFilter(); - } - } - if (mMultiAttachmentView.getVisibility() == View.VISIBLE) { - if (isSelected()) { - mMultiAttachmentView.setColorFilter(selectedImageTint); - } else { - mMultiAttachmentView.clearColorFilter(); - } - } - for (int i = 0, size = mMessageAttachmentsView.getChildCount(); i < size; i++) { - final View attachmentView = mMessageAttachmentsView.getChildAt(i); - if (attachmentView instanceof VideoThumbnailView - && attachmentView.getVisibility() == View.VISIBLE) { - final VideoThumbnailView videoView = (VideoThumbnailView) attachmentView; - if (isSelected()) { - videoView.setColorFilter(selectedImageTint); - } else { - videoView.clearColorFilter(); - } - } - } - - // If there are multiple attachment bubbles in a single message, add some separation. - final int multipleAttachmentPadding = - getResources().getDimensionPixelSize(R.dimen.message_padding_same_author); - - boolean previousVisibleView = false; - for (int i = 0, size = mMessageAttachmentsView.getChildCount(); i < size; i++) { - final View attachmentView = mMessageAttachmentsView.getChildAt(i); - if (attachmentView.getVisibility() == View.VISIBLE) { - final int margin = previousVisibleView ? multipleAttachmentPadding : 0; - ((LinearLayout.LayoutParams) attachmentView.getLayoutParams()).topMargin = margin; - // updateViewAppearance calls requestLayout() at the end, so we don't need to here - previousVisibleView = true; - } - } - } - - private void updateTextAppearance() { - int messageColorResId; - int statusColorResId = -1; - int infoColorResId = -1; - int timestampColorResId; - int subjectLabelColorResId; - if (isSelected()) { - messageColorResId = R.color.message_text_color_incoming; - statusColorResId = R.color.message_action_status_text; - infoColorResId = R.color.message_action_info_text; - if (shouldShowMessageTextBubble()) { - timestampColorResId = R.color.message_action_timestamp_text; - subjectLabelColorResId = R.color.message_action_timestamp_text; - } else { - // If there's no text, the timestamp will be shown below the attachments, - // against the conversation view background. - timestampColorResId = R.color.timestamp_text_outgoing; - subjectLabelColorResId = R.color.timestamp_text_outgoing; - } - } else { - messageColorResId = (mData.getIsIncoming() ? - R.color.message_text_color_incoming : R.color.message_text_color_outgoing); - statusColorResId = messageColorResId; - infoColorResId = R.color.timestamp_text_incoming; - switch(mData.getStatus()) { - - case MessageData.BUGLE_STATUS_OUTGOING_FAILED: - case MessageData.BUGLE_STATUS_OUTGOING_FAILED_EMERGENCY_NUMBER: - timestampColorResId = R.color.message_failed_timestamp_text; - subjectLabelColorResId = R.color.timestamp_text_outgoing; - break; - - case MessageData.BUGLE_STATUS_OUTGOING_YET_TO_SEND: - case MessageData.BUGLE_STATUS_OUTGOING_SENDING: - case MessageData.BUGLE_STATUS_OUTGOING_RESENDING: - case MessageData.BUGLE_STATUS_OUTGOING_AWAITING_RETRY: - case MessageData.BUGLE_STATUS_OUTGOING_COMPLETE: - case MessageData.BUGLE_STATUS_OUTGOING_DELIVERED: - timestampColorResId = R.color.timestamp_text_outgoing; - subjectLabelColorResId = R.color.timestamp_text_outgoing; - break; - - case MessageData.BUGLE_STATUS_INCOMING_EXPIRED_OR_NOT_AVAILABLE: - case MessageData.BUGLE_STATUS_INCOMING_DOWNLOAD_FAILED: - messageColorResId = R.color.message_text_color_incoming_download_failed; - timestampColorResId = R.color.message_download_failed_timestamp_text; - subjectLabelColorResId = R.color.message_text_color_incoming_download_failed; - statusColorResId = R.color.message_download_failed_status_text; - infoColorResId = R.color.message_info_text_incoming_download_failed; - break; - - case MessageData.BUGLE_STATUS_INCOMING_AUTO_DOWNLOADING: - case MessageData.BUGLE_STATUS_INCOMING_MANUAL_DOWNLOADING: - case MessageData.BUGLE_STATUS_INCOMING_RETRYING_AUTO_DOWNLOAD: - case MessageData.BUGLE_STATUS_INCOMING_RETRYING_MANUAL_DOWNLOAD: - case MessageData.BUGLE_STATUS_INCOMING_YET_TO_MANUAL_DOWNLOAD: - timestampColorResId = R.color.message_text_color_incoming; - subjectLabelColorResId = R.color.message_text_color_incoming; - infoColorResId = R.color.timestamp_text_incoming; - break; - - case MessageData.BUGLE_STATUS_INCOMING_COMPLETE: - default: - timestampColorResId = R.color.timestamp_text_incoming; - subjectLabelColorResId = R.color.timestamp_text_incoming; - infoColorResId = -1; // Not used - break; - } - } - final int messageColor = getResources().getColor(messageColorResId); - mMessageTextView.setTextColor(messageColor); - mMessageTextView.setLinkTextColor(messageColor); - mSubjectText.setTextColor(messageColor); - if (statusColorResId >= 0) { - mTitleTextView.setTextColor(getResources().getColor(statusColorResId)); - } - if (infoColorResId >= 0) { - mMmsInfoTextView.setTextColor(getResources().getColor(infoColorResId)); - } - if (timestampColorResId == R.color.timestamp_text_incoming && - mData.hasAttachments() && !shouldShowMessageTextBubble()) { - timestampColorResId = R.color.timestamp_text_outgoing; - } - mStatusTextView.setTextColor(getResources().getColor(timestampColorResId)); - - mSubjectLabel.setTextColor(getResources().getColor(subjectLabelColorResId)); - mSenderNameTextView.setTextColor(getResources().getColor(timestampColorResId)); - } - - /** - * If we don't know the size of the image, we want to show it in a fixed-sized frame to - * avoid janks when the image is loaded and resized. Otherwise, we can set the imageview to - * take on normal layout params. - */ - private void adjustImageViewBounds(final MessagePartData imageAttachment) { - Assert.isTrue(ContentType.isImageType(imageAttachment.getContentType())); - final ViewGroup.LayoutParams layoutParams = mMessageImageView.getLayoutParams(); - if (imageAttachment.getWidth() == MessagePartData.UNSPECIFIED_SIZE || - imageAttachment.getHeight() == MessagePartData.UNSPECIFIED_SIZE) { - // We don't know the size of the image attachment, enable letterboxing on the image - // and show a fixed sized attachment. This should happen at most once per image since - // after the image is loaded we then save the image dimensions to the db so that the - // next time we can display the full size. - layoutParams.width = getResources() - .getDimensionPixelSize(R.dimen.image_attachment_fallback_width); - layoutParams.height = getResources() - .getDimensionPixelSize(R.dimen.image_attachment_fallback_height); - mMessageImageView.setScaleType(ScaleType.CENTER_CROP); - } else { - layoutParams.width = ViewGroup.LayoutParams.MATCH_PARENT; - layoutParams.height = ViewGroup.LayoutParams.WRAP_CONTENT; - // ScaleType.CENTER_INSIDE and FIT_CENTER behave similarly for most images. However, - // FIT_CENTER works better for small images as it enlarges the image such that the - // minimum size ("android:minWidth" etc) is honored. - mMessageImageView.setScaleType(ScaleType.FIT_CENTER); - } - } - - @Override - public void onClick(final View view) { - final Object tag = view.getTag(); - if (tag instanceof MessagePartData) { - final Rect bounds = UiUtils.getMeasuredBoundsOnScreen(view); - onAttachmentClick((MessagePartData) tag, bounds, false /* longPress */); - } else if (tag instanceof String) { - // Currently the only object that would make a tag of a string is a youtube preview - // image - UIIntents.get().launchBrowserForUrl(getContext(), (String) tag); - } - } - - @Override - public boolean onLongClick(final View view) { - if (view == mMessageTextView) { - // Avoid trying to reselect the message - if (isSelected()) { - return false; - } - - // Preemptively handle the long click event on message text so it's not handled by - // the link spans. - return performLongClick(); - } - - final Object tag = view.getTag(); - if (tag instanceof MessagePartData) { - final Rect bounds = UiUtils.getMeasuredBoundsOnScreen(view); - return onAttachmentClick((MessagePartData) tag, bounds, true /* longPress */); - } - - return false; - } - - @Override - public boolean onAttachmentClick(final MessagePartData attachment, - final Rect viewBoundsOnScreen, final boolean longPress) { - return mHost.onAttachmentClick(this, attachment, viewBoundsOnScreen, longPress); - } - - public ContactIconView getContactIconView() { - return mContactIconView; - } - - // Sort photos in MultiAttachLayout in the same order as the ConversationImagePartsView - static final Comparator sImageComparator = new Comparator(){ - @Override - public int compare(final MessagePartData x, final MessagePartData y) { - return x.getPartId().compareTo(y.getPartId()); - } - }; - - static final Predicate sVideoFilter = MessagePartData::isVideo; - - static final Predicate sAudioFilter = MessagePartData::isAudio; - - static final Predicate sVCardFilter = MessagePartData::isVCard; - - static final Predicate sImageFilter = MessagePartData::isImage; - - interface AttachmentViewBinder { - void bindView(View view, MessagePartData attachment); - void unbind(View view); - } - - final AttachmentViewBinder mVideoViewBinder = new AttachmentViewBinder() { - @Override - public void bindView(final View view, final MessagePartData attachment) { - ((VideoThumbnailView) view).setSource(attachment, mData.getIsIncoming()); - } - - @Override - public void unbind(final View view) { - ((VideoThumbnailView) view).setSource((Uri) null, mData.getIsIncoming()); - } - }; - - final AttachmentViewBinder mAudioViewBinder = new AttachmentViewBinder() { - @Override - public void bindView(final View view, final MessagePartData attachment) { - final AudioAttachmentView audioView = (AudioAttachmentView) view; - audioView.bindMessagePartData(attachment, mData.getIsIncoming(), isSelected()); - audioView.setBackground(ConversationDrawables.get().getBubbleDrawable( - isSelected(), mData.getIsIncoming(), false /* needArrow */, - mData.hasIncomingErrorStatus())); - } - - @Override - public void unbind(final View view) { - ((AudioAttachmentView) view).bindMessagePartData(null, mData.getIsIncoming(), false); - } - }; - - final AttachmentViewBinder mVCardViewBinder = new AttachmentViewBinder() { - @Override - public void bindView(final View view, final MessagePartData attachment) { - final PersonItemView personView = (PersonItemView) view; - personView.bind(DataModel.get().createVCardContactItemData(getContext(), - attachment)); - personView.setBackground(ConversationDrawables.get().getBubbleDrawable( - isSelected(), mData.getIsIncoming(), false /* needArrow */, - mData.hasIncomingErrorStatus())); - final int nameTextColorRes; - final int detailsTextColorRes; - if (isSelected()) { - nameTextColorRes = R.color.message_text_color_incoming; - detailsTextColorRes = R.color.message_text_color_incoming; - } else { - nameTextColorRes = mData.getIsIncoming() ? R.color.message_text_color_incoming - : R.color.message_text_color_outgoing; - detailsTextColorRes = mData.getIsIncoming() ? R.color.timestamp_text_incoming - : R.color.timestamp_text_outgoing; - } - personView.setNameTextColor(getResources().getColor(nameTextColorRes)); - personView.setDetailsTextColor(getResources().getColor(detailsTextColorRes)); - } - - @Override - public void unbind(final View view) { - ((PersonItemView) view).bind(null); - } - }; - - /** - * A helper class that allows us to handle long clicks on linkified message text view (i.e. to - * select the message) so it's not handled by the link spans to launch apps for the links. - */ - private static class IgnoreLinkLongClickHelper implements OnLongClickListener, OnTouchListener { - private boolean mIsLongClick; - private final OnLongClickListener mDelegateLongClickListener; - - /** - * Ignore long clicks on linkified texts for a given text view. - * @param textView the TextView to ignore long clicks on - * @param longClickListener a delegate OnLongClickListener to be called when the view is - * long clicked. - */ - public static void ignoreLinkLongClick(final TextView textView, - @Nullable final OnLongClickListener longClickListener) { - final IgnoreLinkLongClickHelper helper = - new IgnoreLinkLongClickHelper(longClickListener); - textView.setOnLongClickListener(helper); - textView.setOnTouchListener(helper); - } - - private IgnoreLinkLongClickHelper(@Nullable final OnLongClickListener longClickListener) { - mDelegateLongClickListener = longClickListener; - } - - @Override - public boolean onLongClick(final View v) { - // Record that this click is a long click. - mIsLongClick = true; - if (mDelegateLongClickListener != null) { - return mDelegateLongClickListener.onLongClick(v); - } - return false; - } - - @Override - public boolean onTouch(final View v, final MotionEvent event) { - if (event.getActionMasked() == MotionEvent.ACTION_UP && mIsLongClick) { - // This touch event is a long click, preemptively handle this touch event so that - // the link span won't get a onClicked() callback. - mIsLongClick = false; - return false; - } - - if (event.getActionMasked() == MotionEvent.ACTION_DOWN) { - mIsLongClick = false; - } - return false; - } - } -} diff --git a/src/com/android/messaging/ui/conversation/ConversationSimSelector.java b/src/com/android/messaging/ui/conversation/ConversationSimSelector.java deleted file mode 100644 index f7e10169..00000000 --- a/src/com/android/messaging/ui/conversation/ConversationSimSelector.java +++ /dev/null @@ -1,122 +0,0 @@ -/* - * Copyright (C) 2015 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.android.messaging.ui.conversation; - -import android.content.Context; -import android.text.TextUtils; - -import com.android.messaging.Factory; -import com.android.messaging.R; -import com.android.messaging.datamodel.data.SubscriptionListData; -import com.android.messaging.datamodel.data.SubscriptionListData.SubscriptionListEntry; -import com.android.messaging.ui.conversation.SimSelectorView.SimSelectorViewListener; -import com.android.messaging.util.AccessibilityUtil; -import com.android.messaging.util.ThreadUtil; - -import androidx.core.util.Pair; - -/** - * Manages showing/hiding the SIM selector in conversation. - */ -abstract class ConversationSimSelector extends ConversationInput { - private SimSelectorView mSimSelectorView; - private Pair mPendingShow; - private boolean mDataReady; - private String mSelectedSimText; - - public ConversationSimSelector(ConversationInputBase baseHost) { - super(baseHost, false); - } - - public void onSubscriptionListDataLoaded(final SubscriptionListData subscriptionListData) { - ensureSimSelectorView(); - mSimSelectorView.bind(subscriptionListData); - mDataReady = subscriptionListData != null && subscriptionListData.hasData(); - if (mPendingShow != null && mDataReady) { - final boolean show = mPendingShow.first; - final boolean animate = mPendingShow.second; - ThreadUtil.getMainThreadHandler().post(new Runnable() { - @Override - public void run() { - // This will No-Op if we are no longer attached to the host. - mConversationInputBase.showHideInternal(ConversationSimSelector.this, - show, animate); - } - }); - mPendingShow = null; - } - } - - private void announcedSelectedSim() { - final Context context = Factory.get().getApplicationContext(); - if (AccessibilityUtil.isTouchExplorationEnabled(context) && - !TextUtils.isEmpty(mSelectedSimText)) { - AccessibilityUtil.announceForAccessibilityCompat( - mSimSelectorView, null, - context.getString(R.string.selected_sim_content_message, mSelectedSimText)); - } - } - - public void setSelected(final SubscriptionListEntry subEntry) { - mSelectedSimText = subEntry == null ? null : subEntry.displayName; - } - - @Override - public boolean show(boolean animate) { - announcedSelectedSim(); - return showHide(true, animate); - } - - @Override - public boolean hide(boolean animate) { - return showHide(false, animate); - } - - private boolean showHide(final boolean show, final boolean animate) { - if (mDataReady) { - mSimSelectorView.showOrHide(show, animate); - return mSimSelectorView.isOpen() == show; - } else { - mPendingShow = Pair.create(show, animate); - return false; - } - } - - private void ensureSimSelectorView() { - if (mSimSelectorView == null) { - // Grab the SIM selector view from the host. This class assumes ownership of it. - mSimSelectorView = getSimSelectorView(); - mSimSelectorView.setItemLayoutId(getSimSelectorItemLayoutId()); - mSimSelectorView.setListener(new SimSelectorViewListener() { - - @Override - public void onSimSelectorVisibilityChanged(boolean visible) { - onVisibilityChanged(visible); - } - - @Override - public void onSimItemClicked(SubscriptionListEntry item) { - selectSim(item); - } - }); - } - } - - protected abstract SimSelectorView getSimSelectorView(); - protected abstract void selectSim(final SubscriptionListEntry item); - protected abstract int getSimSelectorItemLayoutId(); - -} diff --git a/src/com/android/messaging/ui/conversation/v2/ConversationSubscriptionLabelResolver.kt b/src/com/android/messaging/ui/conversation/ConversationSubscriptionLabelResolver.kt similarity index 94% rename from src/com/android/messaging/ui/conversation/v2/ConversationSubscriptionLabelResolver.kt rename to src/com/android/messaging/ui/conversation/ConversationSubscriptionLabelResolver.kt index b0c501b8..e5e1f7bc 100644 --- a/src/com/android/messaging/ui/conversation/v2/ConversationSubscriptionLabelResolver.kt +++ b/src/com/android/messaging/ui/conversation/ConversationSubscriptionLabelResolver.kt @@ -1,4 +1,4 @@ -package com.android.messaging.ui.conversation.v2 +package com.android.messaging.ui.conversation import androidx.compose.runtime.Composable import androidx.compose.ui.res.stringResource diff --git a/src/com/android/messaging/ui/conversation/v2/ConversationTestTags.kt b/src/com/android/messaging/ui/conversation/ConversationTestTags.kt similarity index 98% rename from src/com/android/messaging/ui/conversation/v2/ConversationTestTags.kt rename to src/com/android/messaging/ui/conversation/ConversationTestTags.kt index e2ede120..7614c43e 100644 --- a/src/com/android/messaging/ui/conversation/v2/ConversationTestTags.kt +++ b/src/com/android/messaging/ui/conversation/ConversationTestTags.kt @@ -1,4 +1,4 @@ -package com.android.messaging.ui.conversation.v2 +package com.android.messaging.ui.conversation import androidx.compose.ui.semantics.SemanticsPropertyKey import androidx.compose.ui.semantics.SemanticsPropertyReceiver diff --git a/src/com/android/messaging/ui/conversation/EnterSelfPhoneNumberDialog.java b/src/com/android/messaging/ui/conversation/EnterSelfPhoneNumberDialog.java deleted file mode 100644 index ed5aaabd..00000000 --- a/src/com/android/messaging/ui/conversation/EnterSelfPhoneNumberDialog.java +++ /dev/null @@ -1,93 +0,0 @@ -/* - * Copyright (C) 2015 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.android.messaging.ui.conversation; - -import android.app.AlertDialog; -import android.app.Dialog; -import android.content.Context; -import android.content.DialogInterface; -import android.os.Bundle; -import android.text.TextUtils; -import android.view.LayoutInflater; -import android.widget.EditText; - -import androidx.fragment.app.DialogFragment; - -import com.android.messaging.R; -import com.android.messaging.datamodel.ParticipantRefresh; -import com.android.messaging.util.BuglePrefs; -import com.android.messaging.util.UiUtils; - -/** - * The dialog for the user to enter the phone number of their sim. - */ -public class EnterSelfPhoneNumberDialog extends DialogFragment { - private EditText mEditText; - private int mSubId; - - public static EnterSelfPhoneNumberDialog newInstance(final int subId) { - final EnterSelfPhoneNumberDialog dialog = new EnterSelfPhoneNumberDialog(); - dialog.mSubId = subId; - return dialog; - } - - @Override - public Dialog onCreateDialog(final Bundle savedInstanceState) { - final Context context = getActivity(); - final LayoutInflater inflater = getLayoutInflater(); - mEditText = (EditText) inflater.inflate(R.layout.enter_phone_number_view, null, false); - - final AlertDialog.Builder builder = new AlertDialog.Builder(context); - builder.setTitle(R.string.enter_phone_number_title) - .setMessage(R.string.enter_phone_number_text) - .setView(mEditText) - .setNegativeButton(android.R.string.cancel, - new DialogInterface.OnClickListener() { - @Override - public void onClick(final DialogInterface dialog, - final int button) { - dismiss(); - } - }) - .setPositiveButton(android.R.string.ok, - new DialogInterface.OnClickListener() { - @Override - public void onClick(final DialogInterface dialog, - final int button) { - final String newNumber = mEditText.getText().toString(); - dismiss(); - if (!TextUtils.isEmpty(newNumber)) { - savePhoneNumberInPrefs(newNumber); - // TODO: Remove this toast and just auto-send - // the message instead - UiUtils.showToast( - R.string - .toast_after_setting_default_sms_app_for_message_send); - } - } - }); - return builder.create(); - } - - private void savePhoneNumberInPrefs(final String newPhoneNumber) { - final BuglePrefs subPrefs = BuglePrefs.getSubscriptionPrefs(mSubId); - subPrefs.putString(getString(R.string.mms_phone_number_pref_key), - newPhoneNumber); - // Update the self participants so the new phone number will be reflected - // everywhere in the UI. - ParticipantRefresh.refreshSelfParticipants(); - } -} diff --git a/src/com/android/messaging/ui/conversation/MessageBubbleBackground.java b/src/com/android/messaging/ui/conversation/MessageBubbleBackground.java deleted file mode 100644 index 4c229707..00000000 --- a/src/com/android/messaging/ui/conversation/MessageBubbleBackground.java +++ /dev/null @@ -1,47 +0,0 @@ -/* - * Copyright (C) 2015 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.messaging.ui.conversation; - -import android.content.Context; -import android.util.AttributeSet; -import android.widget.LinearLayout; - -import com.android.messaging.R; - -public class MessageBubbleBackground extends LinearLayout { - private final int mSnapWidthPixels; - - public MessageBubbleBackground(Context context, AttributeSet attrs) { - super(context, attrs); - mSnapWidthPixels = context.getResources().getDimensionPixelSize( - R.dimen.conversation_bubble_width_snap); - } - - @Override - protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { - super.onMeasure(widthMeasureSpec, heightMeasureSpec); - final int widthPadding = getPaddingLeft() + getPaddingRight(); - int bubbleWidth = getMeasuredWidth() - widthPadding; - final int maxWidth = MeasureSpec.getSize(widthMeasureSpec) - widthPadding; - // Round up to next snapWidthPixels - bubbleWidth = Math.min(maxWidth, - (int) (Math.ceil(bubbleWidth / (float) mSnapWidthPixels) * mSnapWidthPixels)); - super.onMeasure( - MeasureSpec.makeMeasureSpec(bubbleWidth + widthPadding, MeasureSpec.EXACTLY), - heightMeasureSpec); - } -} diff --git a/src/com/android/messaging/ui/conversation/SimIconView.java b/src/com/android/messaging/ui/conversation/SimIconView.java deleted file mode 100644 index 551042a8..00000000 --- a/src/com/android/messaging/ui/conversation/SimIconView.java +++ /dev/null @@ -1,54 +0,0 @@ -/* - * Copyright (C) 2015 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.android.messaging.ui.conversation; - -import android.content.Context; -import android.graphics.Outline; -import android.util.AttributeSet; -import android.view.MotionEvent; -import android.view.View; -import android.view.ViewOutlineProvider; - -import com.android.messaging.ui.ContactIconView; - -/** - * Shows SIM avatar icon in the SIM switcher / Self-send button. - */ -public class SimIconView extends ContactIconView { - public SimIconView(Context context, AttributeSet attrs) { - super(context, attrs); - setOutlineProvider(new ViewOutlineProvider() { - @Override - public void getOutline(View v, Outline outline) { - outline.setOval(0, 0, v.getWidth(), v.getHeight()); - } - }); - } - - @Override - public boolean onTouchEvent(MotionEvent event) { - if (isClickable()) { - return super.onTouchEvent(event); - } - return true; - } - - @Override - protected void maybeInitializeOnClickListener() { - // TODO: SIM icon view shouldn't consume or handle clicks, but it should if - // this is the send button for the only SIM in the device or if MSIM is not supported. - } -} diff --git a/src/com/android/messaging/ui/conversation/SimSelectorItemView.java b/src/com/android/messaging/ui/conversation/SimSelectorItemView.java deleted file mode 100644 index 3058d31c..00000000 --- a/src/com/android/messaging/ui/conversation/SimSelectorItemView.java +++ /dev/null @@ -1,90 +0,0 @@ -/* - * Copyright (C) 2015 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.android.messaging.ui.conversation; - -import android.content.Context; -import android.text.TextUtils; -import android.util.AttributeSet; -import android.view.View; -import android.widget.LinearLayout; -import android.widget.TextView; - -import com.android.messaging.R; -import com.android.messaging.datamodel.data.SubscriptionListData.SubscriptionListEntry; -import com.android.messaging.util.Assert; - -/** - * Shows a view for a SIM in the SIM selector. - */ -public class SimSelectorItemView extends LinearLayout { - public interface HostInterface { - void onSimItemClicked(SubscriptionListEntry item); - } - - private SubscriptionListEntry mData; - private TextView mNameTextView; - private TextView mDetailsTextView; - private SimIconView mSimIconView; - private HostInterface mHost; - - public SimSelectorItemView(final Context context, final AttributeSet attrs) { - super(context, attrs); - } - - @Override - protected void onFinishInflate() { - mNameTextView = (TextView) findViewById(R.id.name); - mDetailsTextView = (TextView) findViewById(R.id.details); - mSimIconView = (SimIconView) findViewById(R.id.sim_icon); - setOnClickListener(new OnClickListener() { - @Override - public void onClick(View v) { - mHost.onSimItemClicked(mData); - } - }); - } - - public void bind(final SubscriptionListEntry simEntry) { - Assert.notNull(simEntry); - mData = simEntry; - updateViewAppearance(); - } - - public void setHostInterface(final HostInterface host) { - mHost = host; - } - - private void updateViewAppearance() { - Assert.notNull(mData); - final String displayName = mData.displayName; - if (TextUtils.isEmpty(displayName)) { - mNameTextView.setVisibility(GONE); - } else { - mNameTextView.setVisibility(VISIBLE); - mNameTextView.setText(displayName); - } - - final String details = mData.displayDestination; - if (TextUtils.isEmpty(details)) { - mDetailsTextView.setVisibility(GONE); - } else { - mDetailsTextView.setVisibility(VISIBLE); - mDetailsTextView.setText(details); - } - - mSimIconView.setImageResourceUri(mData.iconUri); - } -} diff --git a/src/com/android/messaging/ui/conversation/SimSelectorView.java b/src/com/android/messaging/ui/conversation/SimSelectorView.java deleted file mode 100644 index b07ff19a..00000000 --- a/src/com/android/messaging/ui/conversation/SimSelectorView.java +++ /dev/null @@ -1,169 +0,0 @@ -/* - * Copyright (C) 2015 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.android.messaging.ui.conversation; - -import android.content.Context; -import android.util.AttributeSet; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.view.animation.Animation; -import android.view.animation.TranslateAnimation; -import android.widget.ArrayAdapter; -import android.widget.FrameLayout; -import android.widget.ListView; - -import com.android.messaging.R; -import com.android.messaging.datamodel.data.SubscriptionListData; -import com.android.messaging.datamodel.data.SubscriptionListData.SubscriptionListEntry; -import com.android.messaging.util.UiUtils; - -import java.util.ArrayList; -import java.util.List; - -/** - * Displays a SIM selector above the compose message view and overlays the message list. - */ -public class SimSelectorView extends FrameLayout implements SimSelectorItemView.HostInterface { - public interface SimSelectorViewListener { - void onSimItemClicked(SubscriptionListEntry item); - void onSimSelectorVisibilityChanged(boolean visible); - } - - private ListView mSimListView; - private final SimSelectorAdapter mAdapter; - private boolean mShow; - private SimSelectorViewListener mListener; - private int mItemLayoutId; - - public SimSelectorView(Context context, AttributeSet attrs) { - super(context, attrs); - mAdapter = new SimSelectorAdapter(getContext()); - } - - @Override - protected void onFinishInflate() { - super.onFinishInflate(); - mSimListView = (ListView) findViewById(R.id.sim_list); - mSimListView.setAdapter(mAdapter); - - // Clicking anywhere outside the switcher list should dismiss. - setOnClickListener(new OnClickListener() { - @Override - public void onClick(View v) { - showOrHide(false, true); - } - }); - } - - public void bind(final SubscriptionListData data) { - mAdapter.bindData(data.getActiveSubscriptionEntriesExcludingDefault()); - } - - public void setItemLayoutId(final int layoutId) { - mItemLayoutId = layoutId; - } - - public void setListener(final SimSelectorViewListener listener) { - mListener = listener; - } - - public void toggleVisibility() { - showOrHide(!mShow, true); - } - - public void showOrHide(final boolean show, final boolean animate) { - final boolean oldShow = mShow; - mShow = show && mAdapter.getCount() > 1; - if (oldShow != mShow) { - if (mListener != null) { - mListener.onSimSelectorVisibilityChanged(mShow); - } - - if (animate) { - // Fade in the background pane. - setVisibility(VISIBLE); - setAlpha(mShow ? 0.0f : 1.0f); - animate().alpha(mShow ? 1.0f : 0.0f) - .setDuration(UiUtils.REVEAL_ANIMATION_DURATION) - .withEndAction(new Runnable() { - @Override - public void run() { - setAlpha(1.0f); - setVisibility(mShow ? VISIBLE : GONE); - } - }); - } else { - setVisibility(mShow ? VISIBLE : GONE); - } - - // Slide in the SIM selector list via a translate animation. - mSimListView.setVisibility(mShow ? VISIBLE : GONE); - if (animate) { - mSimListView.clearAnimation(); - final TranslateAnimation translateAnimation = new TranslateAnimation( - Animation.RELATIVE_TO_SELF, 0, Animation.RELATIVE_TO_SELF, 0, - Animation.RELATIVE_TO_SELF, mShow ? 1.0f : 0.0f, - Animation.RELATIVE_TO_SELF, mShow ? 0.0f : 1.0f); - translateAnimation.setInterpolator(UiUtils.EASE_OUT_INTERPOLATOR); - translateAnimation.setDuration(UiUtils.REVEAL_ANIMATION_DURATION); - mSimListView.startAnimation(translateAnimation); - } - } - } - - /** - * An adapter that takes a list of SubscriptionListEntry and displays them as a list of - * available SIMs in the SIM selector. - */ - private class SimSelectorAdapter extends ArrayAdapter { - public SimSelectorAdapter(final Context context) { - super(context, R.layout.sim_selector_item_view, new ArrayList()); - } - - public void bindData(final List newList) { - clear(); - addAll(newList); - notifyDataSetChanged(); - } - - @Override - public View getView(final int position, final View convertView, final ViewGroup parent) { - SimSelectorItemView itemView; - if (convertView != null && convertView instanceof SimSelectorItemView) { - itemView = (SimSelectorItemView) convertView; - } else { - final LayoutInflater inflater = (LayoutInflater) getContext() - .getSystemService(Context.LAYOUT_INFLATER_SERVICE); - itemView = (SimSelectorItemView) inflater.inflate(mItemLayoutId, - parent, false); - itemView.setHostInterface(SimSelectorView.this); - } - itemView.bind(getItem(position)); - return itemView; - } - } - - @Override - public void onSimItemClicked(SubscriptionListEntry item) { - mListener.onSimItemClicked(item); - showOrHide(false, true); - } - - public boolean isOpen() { - return mShow; - } -} diff --git a/src/com/android/messaging/ui/conversation/v2/addparticipants/AddParticipantsScreen.kt b/src/com/android/messaging/ui/conversation/addparticipants/AddParticipantsScreen.kt similarity index 86% rename from src/com/android/messaging/ui/conversation/v2/addparticipants/AddParticipantsScreen.kt rename to src/com/android/messaging/ui/conversation/addparticipants/AddParticipantsScreen.kt index 2f25929c..e974cf17 100644 --- a/src/com/android/messaging/ui/conversation/v2/addparticipants/AddParticipantsScreen.kt +++ b/src/com/android/messaging/ui/conversation/addparticipants/AddParticipantsScreen.kt @@ -2,7 +2,7 @@ ExperimentalMaterial3Api::class, ) -package com.android.messaging.ui.conversation.v2.addparticipants +package com.android.messaging.ui.conversation.addparticipants import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding @@ -24,15 +24,15 @@ import androidx.compose.ui.res.stringResource import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.android.messaging.R -import com.android.messaging.ui.conversation.v2.ADD_PARTICIPANTS_CONFIRM_BUTTON_TEST_TAG -import com.android.messaging.ui.conversation.v2.addParticipantsContactRowTestTag -import com.android.messaging.ui.conversation.v2.addparticipants.model.AddParticipantsEffect -import com.android.messaging.ui.conversation.v2.addparticipants.model.AddParticipantsUiState -import com.android.messaging.ui.conversation.v2.recipientpicker.RecipientSelectionContent -import com.android.messaging.ui.conversation.v2.recipientpicker.RecipientSelectionContentUiState -import com.android.messaging.ui.conversation.v2.recipientpicker.RecipientSelectionPrimaryActionUiState -import com.android.messaging.ui.conversation.v2.recipientpicker.RecipientSelectionRowDecorators -import com.android.messaging.ui.conversation.v2.recipientpicker.RecipientSelectionStrings +import com.android.messaging.ui.conversation.ADD_PARTICIPANTS_CONFIRM_BUTTON_TEST_TAG +import com.android.messaging.ui.conversation.addParticipantsContactRowTestTag +import com.android.messaging.ui.conversation.addparticipants.model.AddParticipantsEffect +import com.android.messaging.ui.conversation.addparticipants.model.AddParticipantsUiState +import com.android.messaging.ui.conversation.recipientpicker.RecipientSelectionContent +import com.android.messaging.ui.conversation.recipientpicker.RecipientSelectionContentUiState +import com.android.messaging.ui.conversation.recipientpicker.RecipientSelectionPrimaryActionUiState +import com.android.messaging.ui.conversation.recipientpicker.RecipientSelectionRowDecorators +import com.android.messaging.ui.conversation.recipientpicker.RecipientSelectionStrings import com.android.messaging.util.UiUtils import kotlinx.collections.immutable.toImmutableSet diff --git a/src/com/android/messaging/ui/conversation/v2/addparticipants/AddParticipantsViewModel.kt b/src/com/android/messaging/ui/conversation/addparticipants/AddParticipantsViewModel.kt similarity index 96% rename from src/com/android/messaging/ui/conversation/v2/addparticipants/AddParticipantsViewModel.kt rename to src/com/android/messaging/ui/conversation/addparticipants/AddParticipantsViewModel.kt index 8b04a35e..d2de79f1 100644 --- a/src/com/android/messaging/ui/conversation/v2/addparticipants/AddParticipantsViewModel.kt +++ b/src/com/android/messaging/ui/conversation/addparticipants/AddParticipantsViewModel.kt @@ -1,4 +1,4 @@ -package com.android.messaging.ui.conversation.v2.addparticipants +package com.android.messaging.ui.conversation.addparticipants import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel @@ -10,9 +10,9 @@ import com.android.messaging.di.core.MainDispatcher import com.android.messaging.domain.conversation.usecase.participant.IsConversationRecipientLimitExceeded import com.android.messaging.domain.conversation.usecase.participant.ResolveConversationId import com.android.messaging.domain.conversation.usecase.participant.model.ResolveConversationIdResult -import com.android.messaging.ui.conversation.v2.addparticipants.model.AddParticipantsEffect -import com.android.messaging.ui.conversation.v2.addparticipants.model.AddParticipantsUiState -import com.android.messaging.ui.conversation.v2.recipientpicker.delegate.RecipientPickerDelegate +import com.android.messaging.ui.conversation.addparticipants.model.AddParticipantsEffect +import com.android.messaging.ui.conversation.addparticipants.model.AddParticipantsUiState +import com.android.messaging.ui.conversation.recipientpicker.delegate.RecipientPickerDelegate import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject import kotlinx.collections.immutable.ImmutableList diff --git a/src/com/android/messaging/ui/conversation/v2/addparticipants/model/AddParticipantsEffect.kt b/src/com/android/messaging/ui/conversation/addparticipants/model/AddParticipantsEffect.kt similarity index 77% rename from src/com/android/messaging/ui/conversation/v2/addparticipants/model/AddParticipantsEffect.kt rename to src/com/android/messaging/ui/conversation/addparticipants/model/AddParticipantsEffect.kt index c32213a7..4abea796 100644 --- a/src/com/android/messaging/ui/conversation/v2/addparticipants/model/AddParticipantsEffect.kt +++ b/src/com/android/messaging/ui/conversation/addparticipants/model/AddParticipantsEffect.kt @@ -1,4 +1,4 @@ -package com.android.messaging.ui.conversation.v2.addparticipants.model +package com.android.messaging.ui.conversation.addparticipants.model internal sealed interface AddParticipantsEffect { diff --git a/src/com/android/messaging/ui/conversation/v2/addparticipants/model/AddParticipantsUiState.kt b/src/com/android/messaging/ui/conversation/addparticipants/model/AddParticipantsUiState.kt similarity index 79% rename from src/com/android/messaging/ui/conversation/v2/addparticipants/model/AddParticipantsUiState.kt rename to src/com/android/messaging/ui/conversation/addparticipants/model/AddParticipantsUiState.kt index b4c9966c..3d9d3845 100644 --- a/src/com/android/messaging/ui/conversation/v2/addparticipants/model/AddParticipantsUiState.kt +++ b/src/com/android/messaging/ui/conversation/addparticipants/model/AddParticipantsUiState.kt @@ -1,8 +1,8 @@ -package com.android.messaging.ui.conversation.v2.addparticipants.model +package com.android.messaging.ui.conversation.addparticipants.model import androidx.compose.runtime.Immutable import com.android.messaging.data.conversation.model.recipient.ConversationRecipient -import com.android.messaging.ui.conversation.v2.recipientpicker.model.RecipientPickerUiState +import com.android.messaging.ui.conversation.recipientpicker.model.RecipientPickerUiState import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf diff --git a/src/com/android/messaging/ui/conversation/v2/attachment/mapper/ConversationVCardAttachmentUiModelMapper.kt b/src/com/android/messaging/ui/conversation/attachment/mapper/ConversationVCardAttachmentUiModelMapper.kt similarity index 96% rename from src/com/android/messaging/ui/conversation/v2/attachment/mapper/ConversationVCardAttachmentUiModelMapper.kt rename to src/com/android/messaging/ui/conversation/attachment/mapper/ConversationVCardAttachmentUiModelMapper.kt index 01d477b4..29988a92 100644 --- a/src/com/android/messaging/ui/conversation/v2/attachment/mapper/ConversationVCardAttachmentUiModelMapper.kt +++ b/src/com/android/messaging/ui/conversation/attachment/mapper/ConversationVCardAttachmentUiModelMapper.kt @@ -1,9 +1,9 @@ -package com.android.messaging.ui.conversation.v2.attachment.mapper +package com.android.messaging.ui.conversation.attachment.mapper import com.android.messaging.R import com.android.messaging.data.conversation.model.attachment.ConversationVCardAttachmentMetadata import com.android.messaging.data.conversation.model.attachment.ConversationVCardAttachmentType -import com.android.messaging.ui.conversation.v2.attachment.model.ConversationVCardAttachmentUiModel +import com.android.messaging.ui.conversation.attachment.model.ConversationVCardAttachmentUiModel import javax.inject.Inject internal interface ConversationVCardAttachmentUiModelMapper { diff --git a/src/com/android/messaging/ui/conversation/v2/attachment/model/ConversationVCardAttachmentUiModel.kt b/src/com/android/messaging/ui/conversation/attachment/model/ConversationVCardAttachmentUiModel.kt similarity index 86% rename from src/com/android/messaging/ui/conversation/v2/attachment/model/ConversationVCardAttachmentUiModel.kt rename to src/com/android/messaging/ui/conversation/attachment/model/ConversationVCardAttachmentUiModel.kt index c202f87d..07dd0253 100644 --- a/src/com/android/messaging/ui/conversation/v2/attachment/model/ConversationVCardAttachmentUiModel.kt +++ b/src/com/android/messaging/ui/conversation/attachment/model/ConversationVCardAttachmentUiModel.kt @@ -1,4 +1,4 @@ -package com.android.messaging.ui.conversation.v2.attachment.model +package com.android.messaging.ui.conversation.attachment.model import androidx.compose.runtime.Immutable import com.android.messaging.data.conversation.model.attachment.ConversationVCardAttachmentType diff --git a/src/com/android/messaging/ui/conversation/v2/attachment/ui/ConversationMediaThumbnail.kt b/src/com/android/messaging/ui/conversation/attachment/ui/ConversationMediaThumbnail.kt similarity index 99% rename from src/com/android/messaging/ui/conversation/v2/attachment/ui/ConversationMediaThumbnail.kt rename to src/com/android/messaging/ui/conversation/attachment/ui/ConversationMediaThumbnail.kt index 4373869e..faaaaa9a 100644 --- a/src/com/android/messaging/ui/conversation/v2/attachment/ui/ConversationMediaThumbnail.kt +++ b/src/com/android/messaging/ui/conversation/attachment/ui/ConversationMediaThumbnail.kt @@ -1,4 +1,4 @@ -package com.android.messaging.ui.conversation.v2.attachment.ui +package com.android.messaging.ui.conversation.attachment.ui import android.content.Context import android.graphics.Bitmap diff --git a/src/com/android/messaging/ui/conversation/v2/attachment/ui/ConversationMediaThumbnailBitmapLoader.kt b/src/com/android/messaging/ui/conversation/attachment/ui/ConversationMediaThumbnailBitmapLoader.kt similarity index 99% rename from src/com/android/messaging/ui/conversation/v2/attachment/ui/ConversationMediaThumbnailBitmapLoader.kt rename to src/com/android/messaging/ui/conversation/attachment/ui/ConversationMediaThumbnailBitmapLoader.kt index d35b35e6..617d1aa8 100644 --- a/src/com/android/messaging/ui/conversation/v2/attachment/ui/ConversationMediaThumbnailBitmapLoader.kt +++ b/src/com/android/messaging/ui/conversation/attachment/ui/ConversationMediaThumbnailBitmapLoader.kt @@ -1,4 +1,4 @@ -package com.android.messaging.ui.conversation.v2.attachment.ui +package com.android.messaging.ui.conversation.attachment.ui import android.content.ContentResolver import android.graphics.Bitmap diff --git a/src/com/android/messaging/ui/conversation/v2/attachment/ui/ConversationVCardAttachmentCardContent.kt b/src/com/android/messaging/ui/conversation/attachment/ui/ConversationVCardAttachmentCardContent.kt similarity index 97% rename from src/com/android/messaging/ui/conversation/v2/attachment/ui/ConversationVCardAttachmentCardContent.kt rename to src/com/android/messaging/ui/conversation/attachment/ui/ConversationVCardAttachmentCardContent.kt index 660cc221..4b5dff86 100644 --- a/src/com/android/messaging/ui/conversation/v2/attachment/ui/ConversationVCardAttachmentCardContent.kt +++ b/src/com/android/messaging/ui/conversation/attachment/ui/ConversationVCardAttachmentCardContent.kt @@ -1,4 +1,4 @@ -package com.android.messaging.ui.conversation.v2.attachment.ui +package com.android.messaging.ui.conversation.attachment.ui import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box diff --git a/src/com/android/messaging/ui/conversation/v2/audio/ConversationAudioDurationFormatter.kt b/src/com/android/messaging/ui/conversation/audio/ConversationAudioDurationFormatter.kt similarity index 89% rename from src/com/android/messaging/ui/conversation/v2/audio/ConversationAudioDurationFormatter.kt rename to src/com/android/messaging/ui/conversation/audio/ConversationAudioDurationFormatter.kt index a315f277..f1553d58 100644 --- a/src/com/android/messaging/ui/conversation/v2/audio/ConversationAudioDurationFormatter.kt +++ b/src/com/android/messaging/ui/conversation/audio/ConversationAudioDurationFormatter.kt @@ -1,4 +1,4 @@ -package com.android.messaging.ui.conversation.v2.audio +package com.android.messaging.ui.conversation.audio import java.util.Locale diff --git a/src/com/android/messaging/ui/conversation/v2/audio/delegate/ConversationAudioRecordingDelegate.kt b/src/com/android/messaging/ui/conversation/audio/delegate/ConversationAudioRecordingDelegate.kt similarity index 97% rename from src/com/android/messaging/ui/conversation/v2/audio/delegate/ConversationAudioRecordingDelegate.kt rename to src/com/android/messaging/ui/conversation/audio/delegate/ConversationAudioRecordingDelegate.kt index bdcd78c4..acc2f590 100644 --- a/src/com/android/messaging/ui/conversation/v2/audio/delegate/ConversationAudioRecordingDelegate.kt +++ b/src/com/android/messaging/ui/conversation/audio/delegate/ConversationAudioRecordingDelegate.kt @@ -1,4 +1,4 @@ -package com.android.messaging.ui.conversation.v2.audio.delegate +package com.android.messaging.ui.conversation.audio.delegate import android.net.Uri import android.os.SystemClock @@ -6,12 +6,12 @@ import com.android.messaging.data.conversation.model.draft.ConversationDraftAtta import com.android.messaging.data.conversation.model.draft.ConversationDraftPendingAttachment import com.android.messaging.data.conversation.model.draft.ConversationDraftPendingAttachmentKind import com.android.messaging.data.conversation.repository.ConversationSubscriptionsRepository -import com.android.messaging.data.media.repository.ConversationAttachmentRepository +import com.android.messaging.data.media.repository.ConversationAttachmentsRepository import com.android.messaging.di.core.DefaultDispatcher -import com.android.messaging.ui.conversation.v2.audio.model.ConversationAudioRecordingPhase -import com.android.messaging.ui.conversation.v2.audio.model.ConversationAudioRecordingUiState -import com.android.messaging.ui.conversation.v2.common.ConversationScreenDelegate -import com.android.messaging.ui.conversation.v2.composer.delegate.ConversationDraftDelegate +import com.android.messaging.ui.conversation.audio.model.ConversationAudioRecordingPhase +import com.android.messaging.ui.conversation.audio.model.ConversationAudioRecordingUiState +import com.android.messaging.ui.conversation.common.ConversationScreenDelegate +import com.android.messaging.ui.conversation.composer.delegate.ConversationDraftDelegate import com.android.messaging.ui.mediapicker.LevelTrackingMediaRecorder import com.android.messaging.util.ContentType import com.android.messaging.util.LogUtil @@ -50,7 +50,7 @@ internal interface ConversationAudioRecordingDelegate : } internal class ConversationAudioRecordingDelegateImpl @Inject constructor( - private val conversationAttachmentRepository: ConversationAttachmentRepository, + private val conversationAttachmentsRepository: ConversationAttachmentsRepository, private val conversationSubscriptionsRepository: ConversationSubscriptionsRepository, private val conversationDraftDelegate: ConversationDraftDelegate, @param:DefaultDispatcher @@ -608,7 +608,7 @@ internal class ConversationAudioRecordingDelegateImpl @Inject constructor( private suspend fun deleteStoppedRecording(outputUri: Uri?) { outputUri ?: return - conversationAttachmentRepository + conversationAttachmentsRepository .deleteTemporaryAttachment( contentUri = outputUri.toString(), ) diff --git a/src/com/android/messaging/ui/conversation/v2/audio/model/ConversationAudioRecordingUiState.kt b/src/com/android/messaging/ui/conversation/audio/model/ConversationAudioRecordingUiState.kt similarity index 85% rename from src/com/android/messaging/ui/conversation/v2/audio/model/ConversationAudioRecordingUiState.kt rename to src/com/android/messaging/ui/conversation/audio/model/ConversationAudioRecordingUiState.kt index 4a29c516..e18a1f64 100644 --- a/src/com/android/messaging/ui/conversation/v2/audio/model/ConversationAudioRecordingUiState.kt +++ b/src/com/android/messaging/ui/conversation/audio/model/ConversationAudioRecordingUiState.kt @@ -1,4 +1,4 @@ -package com.android.messaging.ui.conversation.v2.audio.model +package com.android.messaging.ui.conversation.audio.model import androidx.compose.runtime.Immutable diff --git a/src/com/android/messaging/ui/conversation/v2/common/ConversationScreenDelegate.kt b/src/com/android/messaging/ui/conversation/common/ConversationScreenDelegate.kt similarity index 82% rename from src/com/android/messaging/ui/conversation/v2/common/ConversationScreenDelegate.kt rename to src/com/android/messaging/ui/conversation/common/ConversationScreenDelegate.kt index e8632baf..0346cbcf 100644 --- a/src/com/android/messaging/ui/conversation/v2/common/ConversationScreenDelegate.kt +++ b/src/com/android/messaging/ui/conversation/common/ConversationScreenDelegate.kt @@ -1,4 +1,4 @@ -package com.android.messaging.ui.conversation.v2.common +package com.android.messaging.ui.conversation.common import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.StateFlow diff --git a/src/com/android/messaging/ui/conversation/v2/composer/delegate/ConversationComposerAttachmentsDelegate.kt b/src/com/android/messaging/ui/conversation/composer/delegate/ConversationComposerAttachmentsDelegate.kt similarity index 93% rename from src/com/android/messaging/ui/conversation/v2/composer/delegate/ConversationComposerAttachmentsDelegate.kt rename to src/com/android/messaging/ui/conversation/composer/delegate/ConversationComposerAttachmentsDelegate.kt index 2e360f19..ce255503 100644 --- a/src/com/android/messaging/ui/conversation/v2/composer/delegate/ConversationComposerAttachmentsDelegate.kt +++ b/src/com/android/messaging/ui/conversation/composer/delegate/ConversationComposerAttachmentsDelegate.kt @@ -1,14 +1,14 @@ -package com.android.messaging.ui.conversation.v2.composer.delegate +package com.android.messaging.ui.conversation.composer.delegate import com.android.messaging.data.conversation.model.attachment.ConversationVCardAttachmentMetadata import com.android.messaging.data.conversation.model.draft.ConversationDraftAttachment import com.android.messaging.data.conversation.model.draft.ConversationDraftPendingAttachment import com.android.messaging.data.conversation.repository.ConversationVCardMetadataRepository import com.android.messaging.di.core.DefaultDispatcher -import com.android.messaging.ui.conversation.v2.attachment.mapper.ConversationVCardAttachmentUiModelMapper -import com.android.messaging.ui.conversation.v2.composer.mapper.ConversationComposerAttachmentUiModelMapper -import com.android.messaging.ui.conversation.v2.composer.model.ComposerAttachmentUiModel -import com.android.messaging.ui.conversation.v2.composer.model.ConversationDraftState +import com.android.messaging.ui.conversation.attachment.mapper.ConversationVCardAttachmentUiModelMapper +import com.android.messaging.ui.conversation.composer.mapper.ConversationComposerAttachmentUiModelMapper +import com.android.messaging.ui.conversation.composer.model.ComposerAttachmentUiModel +import com.android.messaging.ui.conversation.composer.model.ConversationDraftState import javax.inject.Inject import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf diff --git a/src/com/android/messaging/ui/conversation/v2/composer/delegate/ConversationDraftDelegate.kt b/src/com/android/messaging/ui/conversation/composer/delegate/ConversationDraftDelegate.kt similarity index 98% rename from src/com/android/messaging/ui/conversation/v2/composer/delegate/ConversationDraftDelegate.kt rename to src/com/android/messaging/ui/conversation/composer/delegate/ConversationDraftDelegate.kt index d16bf46a..da1a313a 100644 --- a/src/com/android/messaging/ui/conversation/v2/composer/delegate/ConversationDraftDelegate.kt +++ b/src/com/android/messaging/ui/conversation/composer/delegate/ConversationDraftDelegate.kt @@ -1,4 +1,4 @@ -package com.android.messaging.ui.conversation.v2.composer.delegate +package com.android.messaging.ui.conversation.composer.delegate import android.app.Activity import com.android.messaging.R @@ -19,9 +19,9 @@ import com.android.messaging.domain.conversation.usecase.draft.exception.SendCon import com.android.messaging.domain.conversation.usecase.draft.exception.TooManyVideoAttachmentsException import com.android.messaging.domain.conversation.usecase.draft.exception.UnknownConversationRecipientException import com.android.messaging.domain.conversation.usecase.draft.model.ConversationDraftSendProtocol -import com.android.messaging.ui.conversation.v2.common.ConversationScreenDelegate -import com.android.messaging.ui.conversation.v2.composer.model.ConversationDraftState -import com.android.messaging.ui.conversation.v2.screen.model.ConversationScreenEffect +import com.android.messaging.ui.conversation.common.ConversationScreenDelegate +import com.android.messaging.ui.conversation.composer.model.ConversationDraftState +import com.android.messaging.ui.conversation.screen.model.ConversationScreenEffect import com.android.messaging.util.LogUtil import com.android.messaging.util.core.extension.unitFlow import javax.inject.Inject diff --git a/src/com/android/messaging/ui/conversation/v2/composer/delegate/ConversationDraftEditorState.kt b/src/com/android/messaging/ui/conversation/composer/delegate/ConversationDraftEditorState.kt similarity index 99% rename from src/com/android/messaging/ui/conversation/v2/composer/delegate/ConversationDraftEditorState.kt rename to src/com/android/messaging/ui/conversation/composer/delegate/ConversationDraftEditorState.kt index bc33fb93..e875c804 100644 --- a/src/com/android/messaging/ui/conversation/v2/composer/delegate/ConversationDraftEditorState.kt +++ b/src/com/android/messaging/ui/conversation/composer/delegate/ConversationDraftEditorState.kt @@ -1,9 +1,9 @@ -package com.android.messaging.ui.conversation.v2.composer.delegate +package com.android.messaging.ui.conversation.composer.delegate import com.android.messaging.data.conversation.model.draft.ConversationDraft import com.android.messaging.data.conversation.model.draft.ConversationDraftAttachment import com.android.messaging.data.conversation.model.draft.ConversationDraftPendingAttachment -import com.android.messaging.ui.conversation.v2.composer.model.ConversationDraftState +import com.android.messaging.ui.conversation.composer.model.ConversationDraftState import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList diff --git a/src/com/android/messaging/ui/conversation/v2/composer/mapper/ConversationComposerAttachmentUiModelMapper.kt b/src/com/android/messaging/ui/conversation/composer/mapper/ConversationComposerAttachmentUiModelMapper.kt similarity index 95% rename from src/com/android/messaging/ui/conversation/v2/composer/mapper/ConversationComposerAttachmentUiModelMapper.kt rename to src/com/android/messaging/ui/conversation/composer/mapper/ConversationComposerAttachmentUiModelMapper.kt index 9ef02f1d..c2dcf0db 100644 --- a/src/com/android/messaging/ui/conversation/v2/composer/mapper/ConversationComposerAttachmentUiModelMapper.kt +++ b/src/com/android/messaging/ui/conversation/composer/mapper/ConversationComposerAttachmentUiModelMapper.kt @@ -1,11 +1,11 @@ -package com.android.messaging.ui.conversation.v2.composer.mapper +package com.android.messaging.ui.conversation.composer.mapper import com.android.messaging.data.conversation.model.attachment.ConversationVCardAttachmentMetadata import com.android.messaging.data.conversation.model.draft.ConversationDraftAttachment import com.android.messaging.data.conversation.model.draft.ConversationDraftPendingAttachment import com.android.messaging.data.conversation.model.draft.ConversationDraftPendingAttachmentKind -import com.android.messaging.ui.conversation.v2.attachment.mapper.ConversationVCardAttachmentUiModelMapper -import com.android.messaging.ui.conversation.v2.composer.model.ComposerAttachmentUiModel +import com.android.messaging.ui.conversation.attachment.mapper.ConversationVCardAttachmentUiModelMapper +import com.android.messaging.ui.conversation.composer.model.ComposerAttachmentUiModel import com.android.messaging.util.ContentType import javax.inject.Inject import kotlinx.collections.immutable.ImmutableList diff --git a/src/com/android/messaging/ui/conversation/v2/composer/mapper/ConversationComposerUiStateMapper.kt b/src/com/android/messaging/ui/conversation/composer/mapper/ConversationComposerUiStateMapper.kt similarity index 86% rename from src/com/android/messaging/ui/conversation/v2/composer/mapper/ConversationComposerUiStateMapper.kt rename to src/com/android/messaging/ui/conversation/composer/mapper/ConversationComposerUiStateMapper.kt index af97c2f3..37f42a29 100644 --- a/src/com/android/messaging/ui/conversation/v2/composer/mapper/ConversationComposerUiStateMapper.kt +++ b/src/com/android/messaging/ui/conversation/composer/mapper/ConversationComposerUiStateMapper.kt @@ -1,14 +1,14 @@ -package com.android.messaging.ui.conversation.v2.composer.mapper +package com.android.messaging.ui.conversation.composer.mapper import com.android.messaging.data.conversation.model.metadata.ConversationComposerAvailability import com.android.messaging.data.conversation.model.metadata.ConversationSubscription import com.android.messaging.domain.conversation.usecase.draft.model.ConversationDraftSendProtocol -import com.android.messaging.ui.conversation.v2.audio.model.ConversationAudioRecordingPhase -import com.android.messaging.ui.conversation.v2.audio.model.ConversationAudioRecordingUiState -import com.android.messaging.ui.conversation.v2.composer.model.ComposerAttachmentUiModel -import com.android.messaging.ui.conversation.v2.composer.model.ConversationComposerUiState -import com.android.messaging.ui.conversation.v2.composer.model.ConversationDraftState -import com.android.messaging.ui.conversation.v2.composer.model.ConversationSimSelectorUiState +import com.android.messaging.ui.conversation.audio.model.ConversationAudioRecordingPhase +import com.android.messaging.ui.conversation.audio.model.ConversationAudioRecordingUiState +import com.android.messaging.ui.conversation.composer.model.ComposerAttachmentUiModel +import com.android.messaging.ui.conversation.composer.model.ConversationComposerUiState +import com.android.messaging.ui.conversation.composer.model.ConversationDraftState +import com.android.messaging.ui.conversation.composer.model.ConversationSimSelectorUiState import javax.inject.Inject import kotlinx.collections.immutable.ImmutableList diff --git a/src/com/android/messaging/ui/conversation/v2/composer/model/ComposerAttachmentUiModel.kt b/src/com/android/messaging/ui/conversation/composer/model/ComposerAttachmentUiModel.kt similarity index 93% rename from src/com/android/messaging/ui/conversation/v2/composer/model/ComposerAttachmentUiModel.kt rename to src/com/android/messaging/ui/conversation/composer/model/ComposerAttachmentUiModel.kt index 73b038fb..712ab4de 100644 --- a/src/com/android/messaging/ui/conversation/v2/composer/model/ComposerAttachmentUiModel.kt +++ b/src/com/android/messaging/ui/conversation/composer/model/ComposerAttachmentUiModel.kt @@ -1,7 +1,7 @@ -package com.android.messaging.ui.conversation.v2.composer.model +package com.android.messaging.ui.conversation.composer.model import androidx.compose.runtime.Immutable -import com.android.messaging.ui.conversation.v2.attachment.model.ConversationVCardAttachmentUiModel +import com.android.messaging.ui.conversation.attachment.model.ConversationVCardAttachmentUiModel @Immutable internal sealed interface ComposerAttachmentUiModel { diff --git a/src/com/android/messaging/ui/conversation/v2/composer/model/ConversationComposerUiState.kt b/src/com/android/messaging/ui/conversation/composer/model/ConversationComposerUiState.kt similarity index 90% rename from src/com/android/messaging/ui/conversation/v2/composer/model/ConversationComposerUiState.kt rename to src/com/android/messaging/ui/conversation/composer/model/ConversationComposerUiState.kt index 81b6285c..add5eec1 100644 --- a/src/com/android/messaging/ui/conversation/v2/composer/model/ConversationComposerUiState.kt +++ b/src/com/android/messaging/ui/conversation/composer/model/ConversationComposerUiState.kt @@ -1,9 +1,9 @@ -package com.android.messaging.ui.conversation.v2.composer.model +package com.android.messaging.ui.conversation.composer.model import androidx.compose.runtime.Immutable import com.android.messaging.data.conversation.model.metadata.ConversationComposerDisabledReason import com.android.messaging.domain.conversation.usecase.draft.model.ConversationDraftSendProtocol -import com.android.messaging.ui.conversation.v2.audio.model.ConversationAudioRecordingUiState +import com.android.messaging.ui.conversation.audio.model.ConversationAudioRecordingUiState import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf diff --git a/src/com/android/messaging/ui/conversation/v2/composer/model/ConversationDraftState.kt b/src/com/android/messaging/ui/conversation/composer/model/ConversationDraftState.kt similarity index 89% rename from src/com/android/messaging/ui/conversation/v2/composer/model/ConversationDraftState.kt rename to src/com/android/messaging/ui/conversation/composer/model/ConversationDraftState.kt index afac75b2..1c57ba64 100644 --- a/src/com/android/messaging/ui/conversation/v2/composer/model/ConversationDraftState.kt +++ b/src/com/android/messaging/ui/conversation/composer/model/ConversationDraftState.kt @@ -1,4 +1,4 @@ -package com.android.messaging.ui.conversation.v2.composer.model +package com.android.messaging.ui.conversation.composer.model import com.android.messaging.data.conversation.model.draft.ConversationDraft import com.android.messaging.data.conversation.model.draft.ConversationDraftPendingAttachment diff --git a/src/com/android/messaging/ui/conversation/v2/composer/model/ConversationSendActionButtonGestureState.kt b/src/com/android/messaging/ui/conversation/composer/model/ConversationSendActionButtonGestureState.kt similarity index 75% rename from src/com/android/messaging/ui/conversation/v2/composer/model/ConversationSendActionButtonGestureState.kt rename to src/com/android/messaging/ui/conversation/composer/model/ConversationSendActionButtonGestureState.kt index 47fca3fd..125cd5fd 100644 --- a/src/com/android/messaging/ui/conversation/v2/composer/model/ConversationSendActionButtonGestureState.kt +++ b/src/com/android/messaging/ui/conversation/composer/model/ConversationSendActionButtonGestureState.kt @@ -1,4 +1,4 @@ -package com.android.messaging.ui.conversation.v2.composer.model +package com.android.messaging.ui.conversation.composer.model import androidx.compose.runtime.Immutable diff --git a/src/com/android/messaging/ui/conversation/v2/composer/model/ConversationSendActionButtonMode.kt b/src/com/android/messaging/ui/conversation/composer/model/ConversationSendActionButtonMode.kt similarity index 69% rename from src/com/android/messaging/ui/conversation/v2/composer/model/ConversationSendActionButtonMode.kt rename to src/com/android/messaging/ui/conversation/composer/model/ConversationSendActionButtonMode.kt index 9779cdf0..28dfa8e2 100644 --- a/src/com/android/messaging/ui/conversation/v2/composer/model/ConversationSendActionButtonMode.kt +++ b/src/com/android/messaging/ui/conversation/composer/model/ConversationSendActionButtonMode.kt @@ -1,4 +1,4 @@ -package com.android.messaging.ui.conversation.v2.composer.model +package com.android.messaging.ui.conversation.composer.model import androidx.compose.runtime.Immutable diff --git a/src/com/android/messaging/ui/conversation/v2/composer/model/ConversationSimSelectorUiState.kt b/src/com/android/messaging/ui/conversation/composer/model/ConversationSimSelectorUiState.kt similarity index 89% rename from src/com/android/messaging/ui/conversation/v2/composer/model/ConversationSimSelectorUiState.kt rename to src/com/android/messaging/ui/conversation/composer/model/ConversationSimSelectorUiState.kt index beab1a75..968ed11a 100644 --- a/src/com/android/messaging/ui/conversation/v2/composer/model/ConversationSimSelectorUiState.kt +++ b/src/com/android/messaging/ui/conversation/composer/model/ConversationSimSelectorUiState.kt @@ -1,4 +1,4 @@ -package com.android.messaging.ui.conversation.v2.composer.model +package com.android.messaging.ui.conversation.composer.model import androidx.compose.runtime.Immutable import com.android.messaging.data.conversation.model.metadata.ConversationSubscription diff --git a/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationAttachmentPreview.kt b/src/com/android/messaging/ui/conversation/composer/ui/ConversationAttachmentPreview.kt similarity index 95% rename from src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationAttachmentPreview.kt rename to src/com/android/messaging/ui/conversation/composer/ui/ConversationAttachmentPreview.kt index ba7684e2..f42c27d3 100644 --- a/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationAttachmentPreview.kt +++ b/src/com/android/messaging/ui/conversation/composer/ui/ConversationAttachmentPreview.kt @@ -1,4 +1,4 @@ -package com.android.messaging.ui.conversation.v2.composer.ui +package com.android.messaging.ui.conversation.composer.ui import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -38,14 +38,14 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import com.android.messaging.R -import com.android.messaging.ui.conversation.v2.CONVERSATION_ATTACHMENT_PREVIEW_LIST_TEST_TAG -import com.android.messaging.ui.conversation.v2.attachment.model.ConversationVCardAttachmentUiModel -import com.android.messaging.ui.conversation.v2.attachment.ui.ConversationMediaThumbnail -import com.android.messaging.ui.conversation.v2.attachment.ui.ConversationVCardAttachmentCardContent -import com.android.messaging.ui.conversation.v2.audio.formatConversationAudioDuration -import com.android.messaging.ui.conversation.v2.composer.model.ComposerAttachmentUiModel -import com.android.messaging.ui.conversation.v2.conversationAttachmentPreviewItemTestTag -import com.android.messaging.ui.conversation.v2.conversationAttachmentPreviewRemoveButtonTestTag +import com.android.messaging.ui.conversation.CONVERSATION_ATTACHMENT_PREVIEW_LIST_TEST_TAG +import com.android.messaging.ui.conversation.attachment.model.ConversationVCardAttachmentUiModel +import com.android.messaging.ui.conversation.attachment.ui.ConversationMediaThumbnail +import com.android.messaging.ui.conversation.attachment.ui.ConversationVCardAttachmentCardContent +import com.android.messaging.ui.conversation.audio.formatConversationAudioDuration +import com.android.messaging.ui.conversation.composer.model.ComposerAttachmentUiModel +import com.android.messaging.ui.conversation.conversationAttachmentPreviewItemTestTag +import com.android.messaging.ui.conversation.conversationAttachmentPreviewRemoveButtonTestTag import kotlinx.collections.immutable.ImmutableList private val ATTACHMENT_PREVIEW_CORNER_RADIUS = 20.dp diff --git a/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationAudioRecordingBar.kt b/src/com/android/messaging/ui/conversation/composer/ui/ConversationAudioRecordingBar.kt similarity index 96% rename from src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationAudioRecordingBar.kt rename to src/com/android/messaging/ui/conversation/composer/ui/ConversationAudioRecordingBar.kt index f8d82ee6..44677e71 100644 --- a/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationAudioRecordingBar.kt +++ b/src/com/android/messaging/ui/conversation/composer/ui/ConversationAudioRecordingBar.kt @@ -1,4 +1,4 @@ -package com.android.messaging.ui.conversation.v2.composer.ui +package com.android.messaging.ui.conversation.composer.ui import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.animateColorAsState @@ -46,10 +46,10 @@ import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.android.messaging.R -import com.android.messaging.ui.conversation.v2.CONVERSATION_AUDIO_RECORDING_BAR_TEST_TAG -import com.android.messaging.ui.conversation.v2.CONVERSATION_AUDIO_RECORDING_CANCEL_BUTTON_TEST_TAG -import com.android.messaging.ui.conversation.v2.CONVERSATION_AUDIO_RECORDING_LOCK_AFFORDANCE_TEST_TAG -import com.android.messaging.ui.conversation.v2.audio.formatConversationAudioDuration +import com.android.messaging.ui.conversation.CONVERSATION_AUDIO_RECORDING_BAR_TEST_TAG +import com.android.messaging.ui.conversation.CONVERSATION_AUDIO_RECORDING_CANCEL_BUTTON_TEST_TAG +import com.android.messaging.ui.conversation.CONVERSATION_AUDIO_RECORDING_LOCK_AFFORDANCE_TEST_TAG +import com.android.messaging.ui.conversation.audio.formatConversationAudioDuration private const val AUDIO_RECORDING_COLOR_ANIMATION_THRESHOLD = 0.7f diff --git a/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationComposeBar.kt b/src/com/android/messaging/ui/conversation/composer/ui/ConversationComposeBar.kt similarity index 96% rename from src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationComposeBar.kt rename to src/com/android/messaging/ui/conversation/composer/ui/ConversationComposeBar.kt index 32b45fc9..03a5308a 100644 --- a/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationComposeBar.kt +++ b/src/com/android/messaging/ui/conversation/composer/ui/ConversationComposeBar.kt @@ -1,4 +1,4 @@ -package com.android.messaging.ui.conversation.v2.composer.ui +package com.android.messaging.ui.conversation.composer.ui import androidx.compose.animation.AnimatedContent import androidx.compose.animation.ContentTransform @@ -34,14 +34,14 @@ import androidx.compose.ui.platform.testTag import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp import com.android.messaging.domain.conversation.usecase.draft.model.ConversationDraftSendProtocol -import com.android.messaging.ui.conversation.v2.CONVERSATION_COMPOSE_BAR_TEST_TAG -import com.android.messaging.ui.conversation.v2.CONVERSATION_SEND_BUTTON_SHAPE_CIRCLE -import com.android.messaging.ui.conversation.v2.CONVERSATION_SEND_BUTTON_TEST_TAG -import com.android.messaging.ui.conversation.v2.audio.model.ConversationAudioRecordingPhase -import com.android.messaging.ui.conversation.v2.audio.model.ConversationAudioRecordingUiState -import com.android.messaging.ui.conversation.v2.composer.model.ConversationSendActionButtonGestureState -import com.android.messaging.ui.conversation.v2.composer.model.ConversationSendActionButtonMode -import com.android.messaging.ui.conversation.v2.conversationShape +import com.android.messaging.ui.conversation.CONVERSATION_COMPOSE_BAR_TEST_TAG +import com.android.messaging.ui.conversation.CONVERSATION_SEND_BUTTON_SHAPE_CIRCLE +import com.android.messaging.ui.conversation.CONVERSATION_SEND_BUTTON_TEST_TAG +import com.android.messaging.ui.conversation.audio.model.ConversationAudioRecordingPhase +import com.android.messaging.ui.conversation.audio.model.ConversationAudioRecordingUiState +import com.android.messaging.ui.conversation.composer.model.ConversationSendActionButtonGestureState +import com.android.messaging.ui.conversation.composer.model.ConversationSendActionButtonMode +import com.android.messaging.ui.conversation.conversationShape internal val AUDIO_RECORD_CANCEL_THRESHOLD = 96.dp internal val AUDIO_RECORD_LOCK_THRESHOLD = 72.dp diff --git a/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationComposeMessageField.kt b/src/com/android/messaging/ui/conversation/composer/ui/ConversationComposeMessageField.kt similarity index 94% rename from src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationComposeMessageField.kt rename to src/com/android/messaging/ui/conversation/composer/ui/ConversationComposeMessageField.kt index 37dd1aab..50ae5bd8 100644 --- a/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationComposeMessageField.kt +++ b/src/com/android/messaging/ui/conversation/composer/ui/ConversationComposeMessageField.kt @@ -1,4 +1,4 @@ -package com.android.messaging.ui.conversation.v2.composer.ui +package com.android.messaging.ui.conversation.composer.ui import androidx.annotation.StringRes import androidx.compose.foundation.layout.Box @@ -45,12 +45,12 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.window.PopupProperties import com.android.messaging.R import com.android.messaging.domain.conversation.usecase.draft.model.ConversationDraftSendProtocol -import com.android.messaging.ui.conversation.v2.CONVERSATION_ATTACHMENT_AUDIO_MENU_ITEM_TEST_TAG -import com.android.messaging.ui.conversation.v2.CONVERSATION_ATTACHMENT_BUTTON_TEST_TAG -import com.android.messaging.ui.conversation.v2.CONVERSATION_ATTACHMENT_CONTACT_MENU_ITEM_TEST_TAG -import com.android.messaging.ui.conversation.v2.CONVERSATION_ATTACHMENT_MEDIA_MENU_ITEM_TEST_TAG -import com.android.messaging.ui.conversation.v2.CONVERSATION_MMS_INDICATOR_TEST_TAG -import com.android.messaging.ui.conversation.v2.CONVERSATION_TEXT_FIELD_TEST_TAG +import com.android.messaging.ui.conversation.CONVERSATION_ATTACHMENT_AUDIO_MENU_ITEM_TEST_TAG +import com.android.messaging.ui.conversation.CONVERSATION_ATTACHMENT_BUTTON_TEST_TAG +import com.android.messaging.ui.conversation.CONVERSATION_ATTACHMENT_CONTACT_MENU_ITEM_TEST_TAG +import com.android.messaging.ui.conversation.CONVERSATION_ATTACHMENT_MEDIA_MENU_ITEM_TEST_TAG +import com.android.messaging.ui.conversation.CONVERSATION_MMS_INDICATOR_TEST_TAG +import com.android.messaging.ui.conversation.CONVERSATION_TEXT_FIELD_TEST_TAG @Composable internal fun rememberConversationComposeBarPresentation(): ConversationComposeBarPresentation { diff --git a/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationComposerSection.kt b/src/com/android/messaging/ui/conversation/composer/ui/ConversationComposerSection.kt similarity index 92% rename from src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationComposerSection.kt rename to src/com/android/messaging/ui/conversation/composer/ui/ConversationComposerSection.kt index 7e4ca7ce..bd6edfec 100644 --- a/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationComposerSection.kt +++ b/src/com/android/messaging/ui/conversation/composer/ui/ConversationComposerSection.kt @@ -1,12 +1,12 @@ -package com.android.messaging.ui.conversation.v2.composer.ui +package com.android.messaging.ui.conversation.composer.ui import androidx.compose.foundation.layout.Column import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import com.android.messaging.domain.conversation.usecase.draft.model.ConversationDraftSendProtocol -import com.android.messaging.ui.conversation.v2.audio.model.ConversationAudioRecordingUiState -import com.android.messaging.ui.conversation.v2.composer.model.ComposerAttachmentUiModel +import com.android.messaging.ui.conversation.audio.model.ConversationAudioRecordingUiState +import com.android.messaging.ui.conversation.composer.model.ComposerAttachmentUiModel import kotlinx.collections.immutable.ImmutableList @Composable diff --git a/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationSendActionButton.kt b/src/com/android/messaging/ui/conversation/composer/ui/ConversationSendActionButton.kt similarity index 98% rename from src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationSendActionButton.kt rename to src/com/android/messaging/ui/conversation/composer/ui/ConversationSendActionButton.kt index 93911f45..395c77f1 100644 --- a/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationSendActionButton.kt +++ b/src/com/android/messaging/ui/conversation/composer/ui/ConversationSendActionButton.kt @@ -1,4 +1,4 @@ -package com.android.messaging.ui.conversation.v2.composer.ui +package com.android.messaging.ui.conversation.composer.ui import androidx.compose.animation.AnimatedContent import androidx.compose.animation.ContentTransform @@ -46,8 +46,8 @@ import androidx.compose.ui.semantics.onClick import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp import com.android.messaging.R -import com.android.messaging.ui.conversation.v2.composer.model.ConversationSendActionButtonGestureState -import com.android.messaging.ui.conversation.v2.composer.model.ConversationSendActionButtonMode +import com.android.messaging.ui.conversation.composer.model.ConversationSendActionButtonGestureState +import com.android.messaging.ui.conversation.composer.model.ConversationSendActionButtonMode @Immutable private data class ConversationSendActionButtonVisualState( diff --git a/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationSendActionButtonGesture.kt b/src/com/android/messaging/ui/conversation/composer/ui/ConversationSendActionButtonGesture.kt similarity index 97% rename from src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationSendActionButtonGesture.kt rename to src/com/android/messaging/ui/conversation/composer/ui/ConversationSendActionButtonGesture.kt index 0a43fd33..a9a2bc5f 100644 --- a/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationSendActionButtonGesture.kt +++ b/src/com/android/messaging/ui/conversation/composer/ui/ConversationSendActionButtonGesture.kt @@ -1,4 +1,4 @@ -package com.android.messaging.ui.conversation.v2.composer.ui +package com.android.messaging.ui.conversation.composer.ui import androidx.compose.foundation.gestures.awaitEachGesture import androidx.compose.foundation.gestures.awaitFirstDown @@ -11,8 +11,8 @@ import androidx.compose.ui.input.pointer.AwaitPointerEventScope import androidx.compose.ui.input.pointer.PointerId import androidx.compose.ui.input.pointer.PointerInputChange import androidx.compose.ui.input.pointer.pointerInput -import com.android.messaging.ui.conversation.v2.composer.model.ConversationSendActionButtonGestureState -import com.android.messaging.ui.conversation.v2.composer.model.ConversationSendActionButtonMode +import com.android.messaging.ui.conversation.composer.model.ConversationSendActionButtonGestureState +import com.android.messaging.ui.conversation.composer.model.ConversationSendActionButtonMode @Composable internal fun Modifier.conversationSendActionButtonGesture( diff --git a/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationSimSelectorSheet.kt b/src/com/android/messaging/ui/conversation/composer/ui/ConversationSimSelectorSheet.kt similarity index 93% rename from src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationSimSelectorSheet.kt rename to src/com/android/messaging/ui/conversation/composer/ui/ConversationSimSelectorSheet.kt index 8450e457..f01fee1c 100644 --- a/src/com/android/messaging/ui/conversation/v2/composer/ui/ConversationSimSelectorSheet.kt +++ b/src/com/android/messaging/ui/conversation/composer/ui/ConversationSimSelectorSheet.kt @@ -1,4 +1,4 @@ -package com.android.messaging.ui.conversation.v2.composer.ui +package com.android.messaging.ui.conversation.composer.ui import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement @@ -33,10 +33,10 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import com.android.messaging.R import com.android.messaging.data.conversation.model.metadata.ConversationSubscription -import com.android.messaging.ui.conversation.v2.CONVERSATION_SIM_SELECTOR_SHEET_TEST_TAG -import com.android.messaging.ui.conversation.v2.composer.model.ConversationSimSelectorUiState -import com.android.messaging.ui.conversation.v2.conversationSimSelectorItemTestTag -import com.android.messaging.ui.conversation.v2.resolveDisplayName +import com.android.messaging.ui.conversation.CONVERSATION_SIM_SELECTOR_SHEET_TEST_TAG +import com.android.messaging.ui.conversation.composer.model.ConversationSimSelectorUiState +import com.android.messaging.ui.conversation.conversationSimSelectorItemTestTag +import com.android.messaging.ui.conversation.resolveDisplayName private val SHEET_VERTICAL_PADDING = 8.dp diff --git a/src/com/android/messaging/ui/conversation/v2/entry/ConversationEntryViewModel.kt b/src/com/android/messaging/ui/conversation/entry/ConversationEntryViewModel.kt similarity index 97% rename from src/com/android/messaging/ui/conversation/v2/entry/ConversationEntryViewModel.kt rename to src/com/android/messaging/ui/conversation/entry/ConversationEntryViewModel.kt index cafbe112..d8f8451b 100644 --- a/src/com/android/messaging/ui/conversation/v2/entry/ConversationEntryViewModel.kt +++ b/src/com/android/messaging/ui/conversation/entry/ConversationEntryViewModel.kt @@ -1,4 +1,4 @@ -package com.android.messaging.ui.conversation.v2.entry +package com.android.messaging.ui.conversation.entry import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel @@ -10,10 +10,10 @@ import com.android.messaging.di.core.MainDispatcher import com.android.messaging.domain.conversation.usecase.participant.IsConversationRecipientLimitExceeded import com.android.messaging.domain.conversation.usecase.participant.ResolveConversationId import com.android.messaging.domain.conversation.usecase.participant.model.ResolveConversationIdResult -import com.android.messaging.ui.conversation.v2.entry.model.ConversationEntryEffect -import com.android.messaging.ui.conversation.v2.entry.model.ConversationEntryLaunchRequest -import com.android.messaging.ui.conversation.v2.entry.model.ConversationEntryStartupAttachment -import com.android.messaging.ui.conversation.v2.entry.model.ConversationEntryUiState +import com.android.messaging.ui.conversation.entry.model.ConversationEntryEffect +import com.android.messaging.ui.conversation.entry.model.ConversationEntryLaunchRequest +import com.android.messaging.ui.conversation.entry.model.ConversationEntryStartupAttachment +import com.android.messaging.ui.conversation.entry.model.ConversationEntryUiState import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject import kotlinx.collections.immutable.persistentListOf diff --git a/src/com/android/messaging/ui/conversation/v2/entry/NewChatScreen.kt b/src/com/android/messaging/ui/conversation/entry/NewChatScreen.kt similarity index 92% rename from src/com/android/messaging/ui/conversation/v2/entry/NewChatScreen.kt rename to src/com/android/messaging/ui/conversation/entry/NewChatScreen.kt index 200a5045..4b08c3db 100644 --- a/src/com/android/messaging/ui/conversation/v2/entry/NewChatScreen.kt +++ b/src/com/android/messaging/ui/conversation/entry/NewChatScreen.kt @@ -2,7 +2,7 @@ ExperimentalMaterial3Api::class, ) -package com.android.messaging.ui.conversation.v2.entry +package com.android.messaging.ui.conversation.entry import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.EnterTransition @@ -48,17 +48,17 @@ import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.android.messaging.R -import com.android.messaging.ui.conversation.v2.NEW_CHAT_CONTACT_RESOLVING_INDICATOR_TEST_TAG -import com.android.messaging.ui.conversation.v2.NEW_CHAT_CREATE_GROUP_NEXT_BUTTON_TEST_TAG -import com.android.messaging.ui.conversation.v2.newChatContactRowTestTag -import com.android.messaging.ui.conversation.v2.recipientpicker.RecipientPickerModel -import com.android.messaging.ui.conversation.v2.recipientpicker.RecipientPickerViewModel -import com.android.messaging.ui.conversation.v2.recipientpicker.RecipientSelectionContent -import com.android.messaging.ui.conversation.v2.recipientpicker.RecipientSelectionContentUiState -import com.android.messaging.ui.conversation.v2.recipientpicker.RecipientSelectionPrimaryActionUiState -import com.android.messaging.ui.conversation.v2.recipientpicker.RecipientSelectionRowDecorators -import com.android.messaging.ui.conversation.v2.recipientpicker.RecipientSelectionStrings -import com.android.messaging.ui.conversation.v2.recipientpicker.model.RecipientPickerUiState +import com.android.messaging.ui.conversation.NEW_CHAT_CONTACT_RESOLVING_INDICATOR_TEST_TAG +import com.android.messaging.ui.conversation.NEW_CHAT_CREATE_GROUP_NEXT_BUTTON_TEST_TAG +import com.android.messaging.ui.conversation.newChatContactRowTestTag +import com.android.messaging.ui.conversation.recipientpicker.RecipientPickerModel +import com.android.messaging.ui.conversation.recipientpicker.RecipientPickerViewModel +import com.android.messaging.ui.conversation.recipientpicker.RecipientSelectionContent +import com.android.messaging.ui.conversation.recipientpicker.RecipientSelectionContentUiState +import com.android.messaging.ui.conversation.recipientpicker.RecipientSelectionPrimaryActionUiState +import com.android.messaging.ui.conversation.recipientpicker.RecipientSelectionRowDecorators +import com.android.messaging.ui.conversation.recipientpicker.RecipientSelectionStrings +import com.android.messaging.ui.conversation.recipientpicker.model.RecipientPickerUiState import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentSetOf diff --git a/src/com/android/messaging/ui/conversation/v2/entry/model/ConversationEntryEffect.kt b/src/com/android/messaging/ui/conversation/entry/model/ConversationEntryEffect.kt similarity index 75% rename from src/com/android/messaging/ui/conversation/v2/entry/model/ConversationEntryEffect.kt rename to src/com/android/messaging/ui/conversation/entry/model/ConversationEntryEffect.kt index d3f2d852..5d9e64b2 100644 --- a/src/com/android/messaging/ui/conversation/v2/entry/model/ConversationEntryEffect.kt +++ b/src/com/android/messaging/ui/conversation/entry/model/ConversationEntryEffect.kt @@ -1,6 +1,6 @@ -package com.android.messaging.ui.conversation.v2.entry.model +package com.android.messaging.ui.conversation.entry.model -import com.android.messaging.ui.conversation.v2.navigation.RecipientPickerMode +import com.android.messaging.ui.conversation.navigation.RecipientPickerMode internal sealed interface ConversationEntryEffect { diff --git a/src/com/android/messaging/ui/conversation/v2/entry/model/ConversationEntryLaunchRequest.kt b/src/com/android/messaging/ui/conversation/entry/model/ConversationEntryLaunchRequest.kt similarity index 87% rename from src/com/android/messaging/ui/conversation/v2/entry/model/ConversationEntryLaunchRequest.kt rename to src/com/android/messaging/ui/conversation/entry/model/ConversationEntryLaunchRequest.kt index 9777b1be..bf7039a7 100644 --- a/src/com/android/messaging/ui/conversation/v2/entry/model/ConversationEntryLaunchRequest.kt +++ b/src/com/android/messaging/ui/conversation/entry/model/ConversationEntryLaunchRequest.kt @@ -1,4 +1,4 @@ -package com.android.messaging.ui.conversation.v2.entry.model +package com.android.messaging.ui.conversation.entry.model import androidx.compose.runtime.Immutable import com.android.messaging.datamodel.data.MessageData diff --git a/src/com/android/messaging/ui/conversation/v2/entry/model/ConversationEntryUiState.kt b/src/com/android/messaging/ui/conversation/entry/model/ConversationEntryUiState.kt similarity index 94% rename from src/com/android/messaging/ui/conversation/v2/entry/model/ConversationEntryUiState.kt rename to src/com/android/messaging/ui/conversation/entry/model/ConversationEntryUiState.kt index 14c9abab..ed87dca0 100644 --- a/src/com/android/messaging/ui/conversation/v2/entry/model/ConversationEntryUiState.kt +++ b/src/com/android/messaging/ui/conversation/entry/model/ConversationEntryUiState.kt @@ -1,4 +1,4 @@ -package com.android.messaging.ui.conversation.v2.entry.model +package com.android.messaging.ui.conversation.entry.model import androidx.compose.runtime.Immutable import com.android.messaging.data.conversation.model.draft.ConversationDraft diff --git a/src/com/android/messaging/ui/conversation/v2/focus/delegate/ConversationFocusDelegate.kt b/src/com/android/messaging/ui/conversation/focus/delegate/ConversationFocusDelegate.kt similarity index 98% rename from src/com/android/messaging/ui/conversation/v2/focus/delegate/ConversationFocusDelegate.kt rename to src/com/android/messaging/ui/conversation/focus/delegate/ConversationFocusDelegate.kt index 0fe3456f..501e29b2 100644 --- a/src/com/android/messaging/ui/conversation/v2/focus/delegate/ConversationFocusDelegate.kt +++ b/src/com/android/messaging/ui/conversation/focus/delegate/ConversationFocusDelegate.kt @@ -1,4 +1,4 @@ -package com.android.messaging.ui.conversation.v2.focus.delegate +package com.android.messaging.ui.conversation.focus.delegate import com.android.messaging.datamodel.BugleNotifications import com.android.messaging.datamodel.DataModel diff --git a/src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationMediaPicker.kt b/src/com/android/messaging/ui/conversation/mediapicker/ConversationMediaPicker.kt similarity index 97% rename from src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationMediaPicker.kt rename to src/com/android/messaging/ui/conversation/mediapicker/ConversationMediaPicker.kt index b50057cb..e5b3e198 100644 --- a/src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationMediaPicker.kt +++ b/src/com/android/messaging/ui/conversation/mediapicker/ConversationMediaPicker.kt @@ -1,4 +1,4 @@ -package com.android.messaging.ui.conversation.v2.mediapicker +package com.android.messaging.ui.conversation.mediapicker import android.annotation.SuppressLint import android.net.Uri @@ -28,10 +28,10 @@ import androidx.photopicker.compose.EmbeddedPhotoPickerState import androidx.photopicker.compose.ExperimentalPhotoPickerComposeApi import androidx.photopicker.compose.rememberEmbeddedPhotoPickerState import com.android.messaging.data.media.model.ConversationCapturedMedia -import com.android.messaging.ui.conversation.v2.composer.model.ComposerAttachmentUiModel -import com.android.messaging.ui.conversation.v2.mediapicker.camera.BindConversationCameraLifecycleEffect -import com.android.messaging.ui.conversation.v2.mediapicker.camera.ConversationCameraController -import com.android.messaging.ui.conversation.v2.mediapicker.camera.rememberConversationCameraController +import com.android.messaging.ui.conversation.composer.model.ComposerAttachmentUiModel +import com.android.messaging.ui.conversation.mediapicker.camera.BindConversationCameraLifecycleEffect +import com.android.messaging.ui.conversation.mediapicker.camera.ConversationCameraController +import com.android.messaging.ui.conversation.mediapicker.camera.rememberConversationCameraController import com.android.messaging.util.ContentType import com.android.messaging.util.LogUtil import kotlinx.collections.immutable.ImmutableList diff --git a/src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationMediaPickerCaptureRoute.kt b/src/com/android/messaging/ui/conversation/mediapicker/ConversationMediaPickerCaptureRoute.kt similarity index 81% rename from src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationMediaPickerCaptureRoute.kt rename to src/com/android/messaging/ui/conversation/mediapicker/ConversationMediaPickerCaptureRoute.kt index 9e4db6aa..3b7bb745 100644 --- a/src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationMediaPickerCaptureRoute.kt +++ b/src/com/android/messaging/ui/conversation/mediapicker/ConversationMediaPickerCaptureRoute.kt @@ -1,15 +1,15 @@ -package com.android.messaging.ui.conversation.v2.mediapicker +package com.android.messaging.ui.conversation.mediapicker import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.android.messaging.data.media.model.ConversationCapturedMedia -import com.android.messaging.ui.conversation.v2.mediapicker.camera.ConversationCameraController -import com.android.messaging.ui.conversation.v2.mediapicker.camera.handlePhotoCaptureRequest -import com.android.messaging.ui.conversation.v2.mediapicker.camera.handleSwitchCameraRequest -import com.android.messaging.ui.conversation.v2.mediapicker.camera.handleToggleFlashRequest -import com.android.messaging.ui.conversation.v2.mediapicker.camera.handleVideoCaptureRequest -import com.android.messaging.ui.conversation.v2.mediapicker.component.capture.ConversationMediaCaptureContent +import com.android.messaging.ui.conversation.mediapicker.camera.ConversationCameraController +import com.android.messaging.ui.conversation.mediapicker.camera.handlePhotoCaptureRequest +import com.android.messaging.ui.conversation.mediapicker.camera.handleSwitchCameraRequest +import com.android.messaging.ui.conversation.mediapicker.camera.handleToggleFlashRequest +import com.android.messaging.ui.conversation.mediapicker.camera.handleVideoCaptureRequest +import com.android.messaging.ui.conversation.mediapicker.component.capture.ConversationMediaCaptureContent @Composable internal fun ConversationMediaCaptureRoute( diff --git a/src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationMediaPickerCaptureScene.kt b/src/com/android/messaging/ui/conversation/mediapicker/ConversationMediaPickerCaptureScene.kt similarity index 90% rename from src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationMediaPickerCaptureScene.kt rename to src/com/android/messaging/ui/conversation/mediapicker/ConversationMediaPickerCaptureScene.kt index 4d6415ce..f9076a8f 100644 --- a/src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationMediaPickerCaptureScene.kt +++ b/src/com/android/messaging/ui/conversation/mediapicker/ConversationMediaPickerCaptureScene.kt @@ -1,4 +1,4 @@ -package com.android.messaging.ui.conversation.v2.mediapicker +package com.android.messaging.ui.conversation.mediapicker import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.PaddingValues @@ -8,8 +8,8 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.android.messaging.data.media.model.ConversationCapturedMedia -import com.android.messaging.ui.conversation.v2.mediapicker.camera.ConversationCameraController -import com.android.messaging.ui.conversation.v2.mediapicker.component.capture.ConversationMediaCameraPreviewSurface +import com.android.messaging.ui.conversation.mediapicker.camera.ConversationCameraController +import com.android.messaging.ui.conversation.mediapicker.component.capture.ConversationMediaCameraPreviewSurface @Composable internal fun ConversationMediaPickerCaptureScene( diff --git a/src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationMediaPickerOverlay.kt b/src/com/android/messaging/ui/conversation/mediapicker/ConversationMediaPickerOverlay.kt similarity index 94% rename from src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationMediaPickerOverlay.kt rename to src/com/android/messaging/ui/conversation/mediapicker/ConversationMediaPickerOverlay.kt index e0da4cd6..37bce7b3 100644 --- a/src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationMediaPickerOverlay.kt +++ b/src/com/android/messaging/ui/conversation/mediapicker/ConversationMediaPickerOverlay.kt @@ -1,4 +1,4 @@ -package com.android.messaging.ui.conversation.v2.mediapicker +package com.android.messaging.ui.conversation.mediapicker import android.Manifest import androidx.activity.compose.BackHandler @@ -15,8 +15,8 @@ import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.testTag import com.android.messaging.data.media.model.ConversationCapturedMedia -import com.android.messaging.ui.conversation.v2.CONVERSATION_MEDIA_PICKER_OVERLAY_TEST_TAG -import com.android.messaging.ui.conversation.v2.composer.model.ComposerAttachmentUiModel +import com.android.messaging.ui.conversation.CONVERSATION_MEDIA_PICKER_OVERLAY_TEST_TAG +import com.android.messaging.ui.conversation.composer.model.ComposerAttachmentUiModel import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableMap diff --git a/src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationMediaPickerPermission.kt b/src/com/android/messaging/ui/conversation/mediapicker/ConversationMediaPickerPermission.kt similarity index 91% rename from src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationMediaPickerPermission.kt rename to src/com/android/messaging/ui/conversation/mediapicker/ConversationMediaPickerPermission.kt index 161ac172..a8e5b47c 100644 --- a/src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationMediaPickerPermission.kt +++ b/src/com/android/messaging/ui/conversation/mediapicker/ConversationMediaPickerPermission.kt @@ -1,4 +1,4 @@ -package com.android.messaging.ui.conversation.v2.mediapicker +package com.android.messaging.ui.conversation.mediapicker import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -9,7 +9,7 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.SoftwareKeyboardController import androidx.lifecycle.Lifecycle import androidx.lifecycle.compose.LifecycleEventEffect -import com.android.messaging.ui.conversation.v2.mediapicker.model.ConversationMediaPickerPermissionState +import com.android.messaging.ui.conversation.mediapicker.model.ConversationMediaPickerPermissionState @Composable internal fun rememberConversationMediaPickerPermissionState(): diff --git a/src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationMediaPickerScaffold.kt b/src/com/android/messaging/ui/conversation/mediapicker/ConversationMediaPickerScaffold.kt similarity index 95% rename from src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationMediaPickerScaffold.kt rename to src/com/android/messaging/ui/conversation/mediapicker/ConversationMediaPickerScaffold.kt index d03c9505..2feb031d 100644 --- a/src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationMediaPickerScaffold.kt +++ b/src/com/android/messaging/ui/conversation/mediapicker/ConversationMediaPickerScaffold.kt @@ -1,4 +1,4 @@ -package com.android.messaging.ui.conversation.v2.mediapicker +package com.android.messaging.ui.conversation.mediapicker import androidx.compose.animation.AnimatedContent import androidx.compose.animation.ContentTransform @@ -17,9 +17,9 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import com.android.messaging.data.media.model.ConversationCapturedMedia -import com.android.messaging.ui.conversation.v2.composer.model.ComposerAttachmentUiModel -import com.android.messaging.ui.conversation.v2.mediapicker.camera.ConversationCameraController -import com.android.messaging.ui.conversation.v2.mediapicker.component.review.ConversationMediaReviewScene +import com.android.messaging.ui.conversation.composer.model.ComposerAttachmentUiModel +import com.android.messaging.ui.conversation.mediapicker.camera.ConversationCameraController +import com.android.messaging.ui.conversation.mediapicker.component.review.ConversationMediaReviewScene import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableMap diff --git a/src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationMediaPickerSheetScaffold.kt b/src/com/android/messaging/ui/conversation/mediapicker/ConversationMediaPickerSheetScaffold.kt similarity index 98% rename from src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationMediaPickerSheetScaffold.kt rename to src/com/android/messaging/ui/conversation/mediapicker/ConversationMediaPickerSheetScaffold.kt index 7ac12a9b..c1beedb8 100644 --- a/src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationMediaPickerSheetScaffold.kt +++ b/src/com/android/messaging/ui/conversation/mediapicker/ConversationMediaPickerSheetScaffold.kt @@ -1,4 +1,4 @@ -package com.android.messaging.ui.conversation.v2.mediapicker +package com.android.messaging.ui.conversation.mediapicker import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box diff --git a/src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationMediaPickerState.kt b/src/com/android/messaging/ui/conversation/mediapicker/ConversationMediaPickerState.kt similarity index 98% rename from src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationMediaPickerState.kt rename to src/com/android/messaging/ui/conversation/mediapicker/ConversationMediaPickerState.kt index 01b081f0..b558fe1d 100644 --- a/src/com/android/messaging/ui/conversation/v2/mediapicker/ConversationMediaPickerState.kt +++ b/src/com/android/messaging/ui/conversation/mediapicker/ConversationMediaPickerState.kt @@ -1,4 +1,4 @@ -package com.android.messaging.ui.conversation.v2.mediapicker +package com.android.messaging.ui.conversation.mediapicker import android.os.Parcelable import androidx.compose.runtime.Composable diff --git a/src/com/android/messaging/ui/conversation/v2/mediapicker/camera/ConversationCameraController.kt b/src/com/android/messaging/ui/conversation/mediapicker/camera/ConversationCameraController.kt similarity index 99% rename from src/com/android/messaging/ui/conversation/v2/mediapicker/camera/ConversationCameraController.kt rename to src/com/android/messaging/ui/conversation/mediapicker/camera/ConversationCameraController.kt index 4c22ac29..b333e8ee 100644 --- a/src/com/android/messaging/ui/conversation/v2/mediapicker/camera/ConversationCameraController.kt +++ b/src/com/android/messaging/ui/conversation/mediapicker/camera/ConversationCameraController.kt @@ -1,4 +1,4 @@ -package com.android.messaging.ui.conversation.v2.mediapicker.camera +package com.android.messaging.ui.conversation.mediapicker.camera import android.Manifest import android.content.Context diff --git a/src/com/android/messaging/ui/conversation/v2/mediapicker/camera/ConversationCameraEffects.kt b/src/com/android/messaging/ui/conversation/mediapicker/camera/ConversationCameraEffects.kt similarity index 94% rename from src/com/android/messaging/ui/conversation/v2/mediapicker/camera/ConversationCameraEffects.kt rename to src/com/android/messaging/ui/conversation/mediapicker/camera/ConversationCameraEffects.kt index 253cbd18..af41377d 100644 --- a/src/com/android/messaging/ui/conversation/v2/mediapicker/camera/ConversationCameraEffects.kt +++ b/src/com/android/messaging/ui/conversation/mediapicker/camera/ConversationCameraEffects.kt @@ -1,4 +1,4 @@ -package com.android.messaging.ui.conversation.v2.mediapicker.camera +package com.android.messaging.ui.conversation.mediapicker.camera import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect diff --git a/src/com/android/messaging/ui/conversation/v2/mediapicker/camera/ConversationMediaPickerActions.kt b/src/com/android/messaging/ui/conversation/mediapicker/camera/ConversationMediaPickerActions.kt similarity index 96% rename from src/com/android/messaging/ui/conversation/v2/mediapicker/camera/ConversationMediaPickerActions.kt rename to src/com/android/messaging/ui/conversation/mediapicker/camera/ConversationMediaPickerActions.kt index d5bde94f..68998b72 100644 --- a/src/com/android/messaging/ui/conversation/v2/mediapicker/camera/ConversationMediaPickerActions.kt +++ b/src/com/android/messaging/ui/conversation/mediapicker/camera/ConversationMediaPickerActions.kt @@ -1,4 +1,4 @@ -package com.android.messaging.ui.conversation.v2.mediapicker.camera +package com.android.messaging.ui.conversation.mediapicker.camera import com.android.messaging.R import com.android.messaging.data.media.model.ConversationCapturedMedia diff --git a/src/com/android/messaging/ui/conversation/v2/mediapicker/camera/ConversationPhotoFlashMode.kt b/src/com/android/messaging/ui/conversation/mediapicker/camera/ConversationPhotoFlashMode.kt similarity index 88% rename from src/com/android/messaging/ui/conversation/v2/mediapicker/camera/ConversationPhotoFlashMode.kt rename to src/com/android/messaging/ui/conversation/mediapicker/camera/ConversationPhotoFlashMode.kt index 65cae93b..f0c15e97 100644 --- a/src/com/android/messaging/ui/conversation/v2/mediapicker/camera/ConversationPhotoFlashMode.kt +++ b/src/com/android/messaging/ui/conversation/mediapicker/camera/ConversationPhotoFlashMode.kt @@ -1,4 +1,4 @@ -package com.android.messaging.ui.conversation.v2.mediapicker.camera +package com.android.messaging.ui.conversation.mediapicker.camera import androidx.camera.core.ImageCapture diff --git a/src/com/android/messaging/ui/conversation/v2/mediapicker/camera/Exceptions.kt b/src/com/android/messaging/ui/conversation/mediapicker/camera/Exceptions.kt similarity index 96% rename from src/com/android/messaging/ui/conversation/v2/mediapicker/camera/Exceptions.kt rename to src/com/android/messaging/ui/conversation/mediapicker/camera/Exceptions.kt index 35d47176..88f06956 100644 --- a/src/com/android/messaging/ui/conversation/v2/mediapicker/camera/Exceptions.kt +++ b/src/com/android/messaging/ui/conversation/mediapicker/camera/Exceptions.kt @@ -1,4 +1,4 @@ -package com.android.messaging.ui.conversation.v2.mediapicker.camera +package com.android.messaging.ui.conversation.mediapicker.camera import androidx.camera.core.CameraSelector import androidx.camera.core.ImageCaptureException diff --git a/src/com/android/messaging/ui/conversation/v2/mediapicker/component/ConversationMediaPickerShared.kt b/src/com/android/messaging/ui/conversation/mediapicker/component/ConversationMediaPickerShared.kt similarity index 98% rename from src/com/android/messaging/ui/conversation/v2/mediapicker/component/ConversationMediaPickerShared.kt rename to src/com/android/messaging/ui/conversation/mediapicker/component/ConversationMediaPickerShared.kt index 1f0095e6..652d02d8 100644 --- a/src/com/android/messaging/ui/conversation/v2/mediapicker/component/ConversationMediaPickerShared.kt +++ b/src/com/android/messaging/ui/conversation/mediapicker/component/ConversationMediaPickerShared.kt @@ -1,4 +1,4 @@ -package com.android.messaging.ui.conversation.v2.mediapicker.component +package com.android.messaging.ui.conversation.mediapicker.component import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column diff --git a/src/com/android/messaging/ui/conversation/v2/mediapicker/component/capture/ConversationMediaCaptureControls.kt b/src/com/android/messaging/ui/conversation/mediapicker/component/capture/ConversationMediaCaptureControls.kt similarity index 94% rename from src/com/android/messaging/ui/conversation/v2/mediapicker/component/capture/ConversationMediaCaptureControls.kt rename to src/com/android/messaging/ui/conversation/mediapicker/component/capture/ConversationMediaCaptureControls.kt index d4c1d249..1c363cc7 100644 --- a/src/com/android/messaging/ui/conversation/v2/mediapicker/component/capture/ConversationMediaCaptureControls.kt +++ b/src/com/android/messaging/ui/conversation/mediapicker/component/capture/ConversationMediaCaptureControls.kt @@ -1,4 +1,4 @@ -package com.android.messaging.ui.conversation.v2.mediapicker.component.capture +package com.android.messaging.ui.conversation.mediapicker.component.capture import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -27,10 +27,10 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.android.messaging.R -import com.android.messaging.ui.conversation.v2.audio.formatConversationAudioDuration -import com.android.messaging.ui.conversation.v2.mediapicker.ConversationCaptureMode -import com.android.messaging.ui.conversation.v2.mediapicker.camera.ConversationPhotoFlashMode -import com.android.messaging.ui.conversation.v2.mediapicker.component.PickerOverlayIconButton +import com.android.messaging.ui.conversation.audio.formatConversationAudioDuration +import com.android.messaging.ui.conversation.mediapicker.ConversationCaptureMode +import com.android.messaging.ui.conversation.mediapicker.camera.ConversationPhotoFlashMode +import com.android.messaging.ui.conversation.mediapicker.component.PickerOverlayIconButton @Composable internal fun ConversationMediaCaptureTopBar( diff --git a/src/com/android/messaging/ui/conversation/v2/mediapicker/component/capture/ConversationMediaCaptureShutterButton.kt b/src/com/android/messaging/ui/conversation/mediapicker/component/capture/ConversationMediaCaptureShutterButton.kt similarity index 96% rename from src/com/android/messaging/ui/conversation/v2/mediapicker/component/capture/ConversationMediaCaptureShutterButton.kt rename to src/com/android/messaging/ui/conversation/mediapicker/component/capture/ConversationMediaCaptureShutterButton.kt index 6d21c96b..bc808c29 100644 --- a/src/com/android/messaging/ui/conversation/v2/mediapicker/component/capture/ConversationMediaCaptureShutterButton.kt +++ b/src/com/android/messaging/ui/conversation/mediapicker/component/capture/ConversationMediaCaptureShutterButton.kt @@ -1,4 +1,4 @@ -package com.android.messaging.ui.conversation.v2.mediapicker.component.capture +package com.android.messaging.ui.conversation.mediapicker.component.capture import androidx.compose.animation.animateColor import androidx.compose.animation.core.Transition @@ -26,10 +26,10 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import com.android.messaging.ui.conversation.v2.mediapicker.ConversationCaptureMode -import com.android.messaging.ui.conversation.v2.mediapicker.component.capture.ConversationMediaCaptureShutterPhase.Photo -import com.android.messaging.ui.conversation.v2.mediapicker.component.capture.ConversationMediaCaptureShutterPhase.VideoIdle -import com.android.messaging.ui.conversation.v2.mediapicker.component.capture.ConversationMediaCaptureShutterPhase.VideoRecording +import com.android.messaging.ui.conversation.mediapicker.ConversationCaptureMode +import com.android.messaging.ui.conversation.mediapicker.component.capture.ConversationMediaCaptureShutterPhase.Photo +import com.android.messaging.ui.conversation.mediapicker.component.capture.ConversationMediaCaptureShutterPhase.VideoIdle +import com.android.messaging.ui.conversation.mediapicker.component.capture.ConversationMediaCaptureShutterPhase.VideoRecording private val PICKER_SHUTTER_BORDER_WIDTH = 3.dp private val PICKER_SHUTTER_OUTER_SIZE = 78.dp diff --git a/src/com/android/messaging/ui/conversation/v2/mediapicker/component/capture/ConversationMediaPickerCapture.kt b/src/com/android/messaging/ui/conversation/mediapicker/component/capture/ConversationMediaPickerCapture.kt similarity index 93% rename from src/com/android/messaging/ui/conversation/v2/mediapicker/component/capture/ConversationMediaPickerCapture.kt rename to src/com/android/messaging/ui/conversation/mediapicker/component/capture/ConversationMediaPickerCapture.kt index 0b7b13f8..30e63ea0 100644 --- a/src/com/android/messaging/ui/conversation/v2/mediapicker/component/capture/ConversationMediaPickerCapture.kt +++ b/src/com/android/messaging/ui/conversation/mediapicker/component/capture/ConversationMediaPickerCapture.kt @@ -1,4 +1,4 @@ -package com.android.messaging.ui.conversation.v2.mediapicker.component.capture +package com.android.messaging.ui.conversation.mediapicker.component.capture import androidx.camera.compose.CameraXViewfinder import androidx.camera.core.SurfaceRequest @@ -19,9 +19,9 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.android.messaging.R -import com.android.messaging.ui.conversation.v2.mediapicker.ConversationCaptureMode -import com.android.messaging.ui.conversation.v2.mediapicker.camera.ConversationPhotoFlashMode -import com.android.messaging.ui.conversation.v2.mediapicker.component.PermissionFallback +import com.android.messaging.ui.conversation.mediapicker.ConversationCaptureMode +import com.android.messaging.ui.conversation.mediapicker.camera.ConversationPhotoFlashMode +import com.android.messaging.ui.conversation.mediapicker.component.PermissionFallback @Composable internal fun ConversationMediaCameraPreviewSurface( diff --git a/src/com/android/messaging/ui/conversation/v2/mediapicker/component/review/ConversationMediaPickerReview.kt b/src/com/android/messaging/ui/conversation/mediapicker/component/review/ConversationMediaPickerReview.kt similarity index 97% rename from src/com/android/messaging/ui/conversation/v2/mediapicker/component/review/ConversationMediaPickerReview.kt rename to src/com/android/messaging/ui/conversation/mediapicker/component/review/ConversationMediaPickerReview.kt index 28257b1f..8414e312 100644 --- a/src/com/android/messaging/ui/conversation/v2/mediapicker/component/review/ConversationMediaPickerReview.kt +++ b/src/com/android/messaging/ui/conversation/mediapicker/component/review/ConversationMediaPickerReview.kt @@ -1,4 +1,4 @@ -package com.android.messaging.ui.conversation.v2.mediapicker.component.review +package com.android.messaging.ui.conversation.mediapicker.component.review import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -44,10 +44,10 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import com.android.messaging.R -import com.android.messaging.ui.conversation.v2.composer.model.ComposerAttachmentUiModel -import com.android.messaging.ui.conversation.v2.composer.model.ConversationSendActionButtonMode -import com.android.messaging.ui.conversation.v2.composer.ui.ConversationSendActionButton -import com.android.messaging.ui.conversation.v2.mediapicker.component.PickerOverlayIconButton +import com.android.messaging.ui.conversation.composer.model.ComposerAttachmentUiModel +import com.android.messaging.ui.conversation.composer.model.ConversationSendActionButtonMode +import com.android.messaging.ui.conversation.composer.ui.ConversationSendActionButton +import com.android.messaging.ui.conversation.mediapicker.component.PickerOverlayIconButton import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableMap import kotlinx.collections.immutable.persistentMapOf diff --git a/src/com/android/messaging/ui/conversation/v2/mediapicker/component/review/ConversationMediaReviewBackground.kt b/src/com/android/messaging/ui/conversation/mediapicker/component/review/ConversationMediaReviewBackground.kt similarity index 96% rename from src/com/android/messaging/ui/conversation/v2/mediapicker/component/review/ConversationMediaReviewBackground.kt rename to src/com/android/messaging/ui/conversation/mediapicker/component/review/ConversationMediaReviewBackground.kt index d579b00d..3cc9f163 100644 --- a/src/com/android/messaging/ui/conversation/v2/mediapicker/component/review/ConversationMediaReviewBackground.kt +++ b/src/com/android/messaging/ui/conversation/mediapicker/component/review/ConversationMediaReviewBackground.kt @@ -1,4 +1,4 @@ -package com.android.messaging.ui.conversation.v2.mediapicker.component.review +package com.android.messaging.ui.conversation.mediapicker.component.review import androidx.compose.foundation.Image import androidx.compose.foundation.background @@ -19,8 +19,8 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.IntSize import androidx.core.net.toUri -import com.android.messaging.ui.conversation.v2.attachment.ui.loadConversationMediaThumbnailBitmap -import com.android.messaging.ui.conversation.v2.composer.model.ComposerAttachmentUiModel +import com.android.messaging.ui.conversation.attachment.ui.loadConversationMediaThumbnailBitmap +import com.android.messaging.ui.conversation.composer.model.ComposerAttachmentUiModel import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList diff --git a/src/com/android/messaging/ui/conversation/v2/mediapicker/component/review/ConversationMediaReviewBitmapCache.kt b/src/com/android/messaging/ui/conversation/mediapicker/component/review/ConversationMediaReviewBitmapCache.kt similarity index 91% rename from src/com/android/messaging/ui/conversation/v2/mediapicker/component/review/ConversationMediaReviewBitmapCache.kt rename to src/com/android/messaging/ui/conversation/mediapicker/component/review/ConversationMediaReviewBitmapCache.kt index ebfb9071..c09d915c 100644 --- a/src/com/android/messaging/ui/conversation/v2/mediapicker/component/review/ConversationMediaReviewBitmapCache.kt +++ b/src/com/android/messaging/ui/conversation/mediapicker/component/review/ConversationMediaReviewBitmapCache.kt @@ -1,4 +1,4 @@ -package com.android.messaging.ui.conversation.v2.mediapicker.component.review +package com.android.messaging.ui.conversation.mediapicker.component.review import android.graphics.Bitmap import androidx.compose.runtime.Stable diff --git a/src/com/android/messaging/ui/conversation/v2/mediapicker/component/review/ConversationMediaReviewPageCard.kt b/src/com/android/messaging/ui/conversation/mediapicker/component/review/ConversationMediaReviewPageCard.kt similarity index 96% rename from src/com/android/messaging/ui/conversation/v2/mediapicker/component/review/ConversationMediaReviewPageCard.kt rename to src/com/android/messaging/ui/conversation/mediapicker/component/review/ConversationMediaReviewPageCard.kt index 04a63545..cddc2375 100644 --- a/src/com/android/messaging/ui/conversation/v2/mediapicker/component/review/ConversationMediaReviewPageCard.kt +++ b/src/com/android/messaging/ui/conversation/mediapicker/component/review/ConversationMediaReviewPageCard.kt @@ -1,4 +1,4 @@ -package com.android.messaging.ui.conversation.v2.mediapicker.component.review +package com.android.messaging.ui.conversation.mediapicker.component.review import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween @@ -39,9 +39,9 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.util.lerp import androidx.compose.ui.zIndex import com.android.messaging.R -import com.android.messaging.ui.conversation.v2.attachment.ui.ConversationMediaThumbnail -import com.android.messaging.ui.conversation.v2.composer.model.ComposerAttachmentUiModel -import com.android.messaging.ui.conversation.v2.mediapicker.component.PickerOverlayBackgroundButton +import com.android.messaging.ui.conversation.attachment.ui.ConversationMediaThumbnail +import com.android.messaging.ui.conversation.composer.model.ComposerAttachmentUiModel +import com.android.messaging.ui.conversation.mediapicker.component.PickerOverlayBackgroundButton import kotlin.math.absoluteValue import kotlinx.collections.immutable.ImmutableList import kotlinx.coroutines.delay diff --git a/src/com/android/messaging/ui/conversation/v2/mediapicker/component/review/ConversationMediaReviewPagerState.kt b/src/com/android/messaging/ui/conversation/mediapicker/component/review/ConversationMediaReviewPagerState.kt similarity index 97% rename from src/com/android/messaging/ui/conversation/v2/mediapicker/component/review/ConversationMediaReviewPagerState.kt rename to src/com/android/messaging/ui/conversation/mediapicker/component/review/ConversationMediaReviewPagerState.kt index c3059292..2c10328e 100644 --- a/src/com/android/messaging/ui/conversation/v2/mediapicker/component/review/ConversationMediaReviewPagerState.kt +++ b/src/com/android/messaging/ui/conversation/mediapicker/component/review/ConversationMediaReviewPagerState.kt @@ -1,4 +1,4 @@ -package com.android.messaging.ui.conversation.v2.mediapicker.component.review +package com.android.messaging.ui.conversation.mediapicker.component.review import androidx.compose.animation.core.tween import androidx.compose.foundation.pager.PagerState @@ -6,7 +6,7 @@ import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember -import com.android.messaging.ui.conversation.v2.composer.model.ComposerAttachmentUiModel +import com.android.messaging.ui.conversation.composer.model.ComposerAttachmentUiModel import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableMap import kotlinx.collections.immutable.toImmutableList diff --git a/src/com/android/messaging/ui/conversation/v2/mediapicker/delegate/ConversationMediaPickerDelegate.kt b/src/com/android/messaging/ui/conversation/mediapicker/delegate/ConversationMediaPickerDelegate.kt similarity index 95% rename from src/com/android/messaging/ui/conversation/v2/mediapicker/delegate/ConversationMediaPickerDelegate.kt rename to src/com/android/messaging/ui/conversation/mediapicker/delegate/ConversationMediaPickerDelegate.kt index bd4bb02e..8a87430c 100644 --- a/src/com/android/messaging/ui/conversation/v2/mediapicker/delegate/ConversationMediaPickerDelegate.kt +++ b/src/com/android/messaging/ui/conversation/mediapicker/delegate/ConversationMediaPickerDelegate.kt @@ -1,14 +1,14 @@ -package com.android.messaging.ui.conversation.v2.mediapicker.delegate +package com.android.messaging.ui.conversation.mediapicker.delegate import com.android.messaging.R import com.android.messaging.data.conversation.model.draft.PhotoPickerDraftAttachment import com.android.messaging.data.media.model.ConversationCapturedMedia import com.android.messaging.data.media.model.PhotoPickerDraftAttachmentResult -import com.android.messaging.data.media.repository.ConversationAttachmentRepository +import com.android.messaging.data.media.repository.ConversationAttachmentsRepository import com.android.messaging.di.core.DefaultDispatcher -import com.android.messaging.ui.conversation.v2.composer.delegate.ConversationDraftDelegate -import com.android.messaging.ui.conversation.v2.mediapicker.mapper.ConversationDraftAttachmentMapper -import com.android.messaging.ui.conversation.v2.screen.model.ConversationScreenEffect +import com.android.messaging.ui.conversation.composer.delegate.ConversationDraftDelegate +import com.android.messaging.ui.conversation.mediapicker.mapper.ConversationDraftAttachmentMapper +import com.android.messaging.ui.conversation.screen.model.ConversationScreenEffect import com.android.messaging.util.LogUtil import javax.inject.Inject import kotlinx.collections.immutable.ImmutableMap @@ -57,7 +57,7 @@ internal interface ConversationMediaPickerDelegate { internal class ConversationMediaPickerDelegateImpl @Inject constructor( private val conversationDraftDelegate: ConversationDraftDelegate, - private val conversationAttachmentRepository: ConversationAttachmentRepository, + private val conversationAttachmentsRepository: ConversationAttachmentsRepository, private val conversationDraftAttachmentMapper: ConversationDraftAttachmentMapper, @param:DefaultDispatcher private val defaultDispatcher: CoroutineDispatcher, @@ -116,7 +116,7 @@ internal class ConversationMediaPickerDelegateImpl @Inject constructor( private fun launchPhotoPickerAttachmentResolution(contentUris: List) { boundScope?.launch(defaultDispatcher) { - conversationAttachmentRepository + conversationAttachmentsRepository .createDraftAttachmentsFromPhotoPicker(contentUris = contentUris) .catch { throwable -> handlePhotoPickerAttachmentResolutionException( @@ -241,7 +241,7 @@ internal class ConversationMediaPickerDelegateImpl @Inject constructor( val resolvedContactUri = contactUri?.takeIf { it.isNotBlank() } ?: return boundScope?.launch(defaultDispatcher) { - conversationAttachmentRepository + conversationAttachmentsRepository .createDraftAttachmentFromContact(contactUri = resolvedContactUri) .filterNotNull() .map(::listOf) @@ -334,7 +334,7 @@ internal class ConversationMediaPickerDelegateImpl @Inject constructor( private fun deleteTemporaryAttachment(contentUri: String) { boundScope?.launch(defaultDispatcher) { - conversationAttachmentRepository + conversationAttachmentsRepository .deleteTemporaryAttachment(contentUri = contentUri) .collect() } diff --git a/src/com/android/messaging/ui/conversation/v2/mediapicker/mapper/ConversationDraftAttachmentMapper.kt b/src/com/android/messaging/ui/conversation/mediapicker/mapper/ConversationDraftAttachmentMapper.kt similarity index 94% rename from src/com/android/messaging/ui/conversation/v2/mediapicker/mapper/ConversationDraftAttachmentMapper.kt rename to src/com/android/messaging/ui/conversation/mediapicker/mapper/ConversationDraftAttachmentMapper.kt index 95ad9501..5d6a6be2 100644 --- a/src/com/android/messaging/ui/conversation/v2/mediapicker/mapper/ConversationDraftAttachmentMapper.kt +++ b/src/com/android/messaging/ui/conversation/mediapicker/mapper/ConversationDraftAttachmentMapper.kt @@ -1,4 +1,4 @@ -package com.android.messaging.ui.conversation.v2.mediapicker.mapper +package com.android.messaging.ui.conversation.mediapicker.mapper import com.android.messaging.data.conversation.model.draft.ConversationDraftAttachment import com.android.messaging.data.media.model.ConversationCapturedMedia diff --git a/src/com/android/messaging/ui/conversation/v2/mediapicker/model/ConversationMediaPickerPermissionState.kt b/src/com/android/messaging/ui/conversation/mediapicker/model/ConversationMediaPickerPermissionState.kt similarity index 95% rename from src/com/android/messaging/ui/conversation/v2/mediapicker/model/ConversationMediaPickerPermissionState.kt rename to src/com/android/messaging/ui/conversation/mediapicker/model/ConversationMediaPickerPermissionState.kt index 5081bdc9..d49702a8 100644 --- a/src/com/android/messaging/ui/conversation/v2/mediapicker/model/ConversationMediaPickerPermissionState.kt +++ b/src/com/android/messaging/ui/conversation/mediapicker/model/ConversationMediaPickerPermissionState.kt @@ -1,4 +1,4 @@ -package com.android.messaging.ui.conversation.v2.mediapicker.model +package com.android.messaging.ui.conversation.mediapicker.model import android.Manifest import android.content.Context diff --git a/src/com/android/messaging/ui/conversation/v2/messages/delegate/ConversationMessageSelectionDelegate.kt b/src/com/android/messaging/ui/conversation/messages/delegate/ConversationMessageSelectionDelegate.kt similarity index 94% rename from src/com/android/messaging/ui/conversation/v2/messages/delegate/ConversationMessageSelectionDelegate.kt rename to src/com/android/messaging/ui/conversation/messages/delegate/ConversationMessageSelectionDelegate.kt index 938b5088..eece0cee 100644 --- a/src/com/android/messaging/ui/conversation/v2/messages/delegate/ConversationMessageSelectionDelegate.kt +++ b/src/com/android/messaging/ui/conversation/messages/delegate/ConversationMessageSelectionDelegate.kt @@ -1,4 +1,4 @@ -package com.android.messaging.ui.conversation.v2.messages.delegate +package com.android.messaging.ui.conversation.messages.delegate import android.app.Activity import android.content.ClipData @@ -6,19 +6,19 @@ import android.content.ClipboardManager import com.android.messaging.R import com.android.messaging.data.conversation.repository.ConversationsRepository import com.android.messaging.data.media.model.AttachmentToSave -import com.android.messaging.data.media.repository.ConversationAttachmentRepository +import com.android.messaging.data.media.repository.ConversationAttachmentsRepository 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.domain.conversation.usecase.forward.CreateForwardedMessage -import com.android.messaging.ui.conversation.v2.common.ConversationScreenDelegate -import com.android.messaging.ui.conversation.v2.messages.model.message.ConversationMessagePartUiModel -import com.android.messaging.ui.conversation.v2.messages.model.message.ConversationMessageUiModel -import com.android.messaging.ui.conversation.v2.messages.model.message.ConversationMessagesUiState -import com.android.messaging.ui.conversation.v2.screen.model.ConversationMessageDeleteConfirmationUiState -import com.android.messaging.ui.conversation.v2.screen.model.ConversationMessageSelectionAction -import com.android.messaging.ui.conversation.v2.screen.model.ConversationMessageSelectionUiState -import com.android.messaging.ui.conversation.v2.screen.model.ConversationScreenEffect +import com.android.messaging.ui.conversation.common.ConversationScreenDelegate +import com.android.messaging.ui.conversation.messages.model.message.ConversationMessagePartUiModel +import com.android.messaging.ui.conversation.messages.model.message.ConversationMessageUiModel +import com.android.messaging.ui.conversation.messages.model.message.ConversationMessagesUiState +import com.android.messaging.ui.conversation.screen.model.ConversationMessageDeleteConfirmationUiState +import com.android.messaging.ui.conversation.screen.model.ConversationMessageSelectionAction +import com.android.messaging.ui.conversation.screen.model.ConversationMessageSelectionUiState +import com.android.messaging.ui.conversation.screen.model.ConversationScreenEffect import javax.inject.Inject import kotlinx.collections.immutable.ImmutableSet import kotlinx.collections.immutable.persistentSetOf @@ -59,7 +59,7 @@ internal interface ConversationMessageSelectionDelegate : internal class ConversationMessageSelectionDelegateImpl @Inject constructor( private val checkConversationActionRequirements: CheckConversationActionRequirements, private val clipboardManager: ClipboardManager, - private val conversationAttachmentRepository: ConversationAttachmentRepository, + private val conversationAttachmentsRepository: ConversationAttachmentsRepository, private val conversationMessagesDelegate: ConversationMessagesDelegate, private val createForwardedMessage: CreateForwardedMessage, private val conversationsRepository: ConversationsRepository, @@ -384,7 +384,7 @@ internal class ConversationMessageSelectionDelegateImpl @Inject constructor( } boundScope?.launch(defaultDispatcher) { - conversationAttachmentRepository + conversationAttachmentsRepository .saveAttachmentsToMediaStore(attachments = attachments) .collect { result -> _effects.emit( diff --git a/src/com/android/messaging/ui/conversation/v2/messages/delegate/ConversationMessagesDelegate.kt b/src/com/android/messaging/ui/conversation/messages/delegate/ConversationMessagesDelegate.kt similarity index 91% rename from src/com/android/messaging/ui/conversation/v2/messages/delegate/ConversationMessagesDelegate.kt rename to src/com/android/messaging/ui/conversation/messages/delegate/ConversationMessagesDelegate.kt index d2bf7ca8..a6a0effa 100644 --- a/src/com/android/messaging/ui/conversation/v2/messages/delegate/ConversationMessagesDelegate.kt +++ b/src/com/android/messaging/ui/conversation/messages/delegate/ConversationMessagesDelegate.kt @@ -1,15 +1,15 @@ -package com.android.messaging.ui.conversation.v2.messages.delegate +package com.android.messaging.ui.conversation.messages.delegate import com.android.messaging.data.conversation.model.attachment.ConversationVCardAttachmentMetadata import com.android.messaging.data.conversation.repository.ConversationVCardMetadataRepository import com.android.messaging.data.conversation.repository.ConversationsRepository import com.android.messaging.di.core.DefaultDispatcher -import com.android.messaging.ui.conversation.v2.attachment.mapper.ConversationVCardAttachmentUiModelMapper -import com.android.messaging.ui.conversation.v2.common.ConversationScreenDelegate -import com.android.messaging.ui.conversation.v2.messages.mapper.ConversationMessageUiModelMapper -import com.android.messaging.ui.conversation.v2.messages.model.message.ConversationMessagePartUiModel -import com.android.messaging.ui.conversation.v2.messages.model.message.ConversationMessageUiModel -import com.android.messaging.ui.conversation.v2.messages.model.message.ConversationMessagesUiState +import com.android.messaging.ui.conversation.attachment.mapper.ConversationVCardAttachmentUiModelMapper +import com.android.messaging.ui.conversation.common.ConversationScreenDelegate +import com.android.messaging.ui.conversation.messages.mapper.ConversationMessageUiModelMapper +import com.android.messaging.ui.conversation.messages.model.message.ConversationMessagePartUiModel +import com.android.messaging.ui.conversation.messages.model.message.ConversationMessageUiModel +import com.android.messaging.ui.conversation.messages.model.message.ConversationMessagesUiState import javax.inject.Inject import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList diff --git a/src/com/android/messaging/ui/conversation/v2/messages/mapper/ConversationMessageUiModelMapper.kt b/src/com/android/messaging/ui/conversation/messages/mapper/ConversationMessageUiModelMapper.kt similarity index 94% rename from src/com/android/messaging/ui/conversation/v2/messages/mapper/ConversationMessageUiModelMapper.kt rename to src/com/android/messaging/ui/conversation/messages/mapper/ConversationMessageUiModelMapper.kt index 27678efa..6a317dd0 100644 --- a/src/com/android/messaging/ui/conversation/v2/messages/mapper/ConversationMessageUiModelMapper.kt +++ b/src/com/android/messaging/ui/conversation/messages/mapper/ConversationMessageUiModelMapper.kt @@ -1,12 +1,12 @@ -package com.android.messaging.ui.conversation.v2.messages.mapper +package com.android.messaging.ui.conversation.messages.mapper import com.android.messaging.datamodel.data.ConversationMessageData import com.android.messaging.datamodel.data.MessageData import com.android.messaging.datamodel.data.MessagePartData -import com.android.messaging.ui.conversation.v2.attachment.mapper.ConversationVCardAttachmentUiModelMapper -import com.android.messaging.ui.conversation.v2.messages.model.message.ConversationMessagePartUiModel -import com.android.messaging.ui.conversation.v2.messages.model.message.ConversationMessageUiModel -import com.android.messaging.ui.conversation.v2.messages.model.message.ConversationMessageUiModel.Status +import com.android.messaging.ui.conversation.attachment.mapper.ConversationVCardAttachmentUiModelMapper +import com.android.messaging.ui.conversation.messages.model.message.ConversationMessagePartUiModel +import com.android.messaging.ui.conversation.messages.model.message.ConversationMessageUiModel +import com.android.messaging.ui.conversation.messages.model.message.ConversationMessageUiModel.Status import com.android.messaging.util.ContentType import com.android.messaging.util.LogUtil import javax.inject.Inject diff --git a/src/com/android/messaging/ui/conversation/v2/messages/model/attachment/ConversationAttachmentOpenAction.kt b/src/com/android/messaging/ui/conversation/messages/model/attachment/ConversationAttachmentOpenAction.kt similarity index 83% rename from src/com/android/messaging/ui/conversation/v2/messages/model/attachment/ConversationAttachmentOpenAction.kt rename to src/com/android/messaging/ui/conversation/messages/model/attachment/ConversationAttachmentOpenAction.kt index fbbcebdc..29058b0f 100644 --- a/src/com/android/messaging/ui/conversation/v2/messages/model/attachment/ConversationAttachmentOpenAction.kt +++ b/src/com/android/messaging/ui/conversation/messages/model/attachment/ConversationAttachmentOpenAction.kt @@ -1,4 +1,4 @@ -package com.android.messaging.ui.conversation.v2.messages.model.attachment +package com.android.messaging.ui.conversation.messages.model.attachment import androidx.compose.runtime.Immutable diff --git a/src/com/android/messaging/ui/conversation/v2/messages/model/attachment/ConversationAttachmentSections.kt b/src/com/android/messaging/ui/conversation/messages/model/attachment/ConversationAttachmentSections.kt similarity index 90% rename from src/com/android/messaging/ui/conversation/v2/messages/model/attachment/ConversationAttachmentSections.kt rename to src/com/android/messaging/ui/conversation/messages/model/attachment/ConversationAttachmentSections.kt index 56669896..f0d2e29f 100644 --- a/src/com/android/messaging/ui/conversation/v2/messages/model/attachment/ConversationAttachmentSections.kt +++ b/src/com/android/messaging/ui/conversation/messages/model/attachment/ConversationAttachmentSections.kt @@ -1,4 +1,4 @@ -package com.android.messaging.ui.conversation.v2.messages.model.attachment +package com.android.messaging.ui.conversation.messages.model.attachment import androidx.compose.runtime.Immutable import kotlinx.collections.immutable.ImmutableList diff --git a/src/com/android/messaging/ui/conversation/v2/messages/model/attachment/ConversationInlineAttachment.kt b/src/com/android/messaging/ui/conversation/messages/model/attachment/ConversationInlineAttachment.kt similarity index 94% rename from src/com/android/messaging/ui/conversation/v2/messages/model/attachment/ConversationInlineAttachment.kt rename to src/com/android/messaging/ui/conversation/messages/model/attachment/ConversationInlineAttachment.kt index ca809598..ec353e98 100644 --- a/src/com/android/messaging/ui/conversation/v2/messages/model/attachment/ConversationInlineAttachment.kt +++ b/src/com/android/messaging/ui/conversation/messages/model/attachment/ConversationInlineAttachment.kt @@ -1,4 +1,4 @@ -package com.android.messaging.ui.conversation.v2.messages.model.attachment +package com.android.messaging.ui.conversation.messages.model.attachment import androidx.compose.runtime.Immutable import com.android.messaging.data.conversation.model.attachment.ConversationVCardAttachmentType diff --git a/src/com/android/messaging/ui/conversation/v2/messages/model/attachment/ConversationMessageAttachment.kt b/src/com/android/messaging/ui/conversation/messages/model/attachment/ConversationMessageAttachment.kt similarity index 79% rename from src/com/android/messaging/ui/conversation/v2/messages/model/attachment/ConversationMessageAttachment.kt rename to src/com/android/messaging/ui/conversation/messages/model/attachment/ConversationMessageAttachment.kt index 359cfd98..9ff880a3 100644 --- a/src/com/android/messaging/ui/conversation/v2/messages/model/attachment/ConversationMessageAttachment.kt +++ b/src/com/android/messaging/ui/conversation/messages/model/attachment/ConversationMessageAttachment.kt @@ -1,7 +1,7 @@ -package com.android.messaging.ui.conversation.v2.messages.model.attachment +package com.android.messaging.ui.conversation.messages.model.attachment import androidx.compose.runtime.Immutable -import com.android.messaging.ui.conversation.v2.messages.model.message.ConversationMessagePartUiModel +import com.android.messaging.ui.conversation.messages.model.message.ConversationMessagePartUiModel @Immutable internal sealed interface ConversationMessageAttachment { diff --git a/src/com/android/messaging/ui/conversation/v2/messages/model/message/ConversationMessageContent.kt b/src/com/android/messaging/ui/conversation/messages/model/message/ConversationMessageContent.kt similarity index 57% rename from src/com/android/messaging/ui/conversation/v2/messages/model/message/ConversationMessageContent.kt rename to src/com/android/messaging/ui/conversation/messages/model/message/ConversationMessageContent.kt index 67b928fe..1452d37c 100644 --- a/src/com/android/messaging/ui/conversation/v2/messages/model/message/ConversationMessageContent.kt +++ b/src/com/android/messaging/ui/conversation/messages/model/message/ConversationMessageContent.kt @@ -1,8 +1,8 @@ -package com.android.messaging.ui.conversation.v2.messages.model.message +package com.android.messaging.ui.conversation.messages.model.message import androidx.compose.runtime.Immutable -import com.android.messaging.ui.conversation.v2.messages.model.attachment.ConversationAttachmentSections -import com.android.messaging.ui.conversation.v2.messages.model.attachment.ConversationMessageAttachment +import com.android.messaging.ui.conversation.messages.model.attachment.ConversationAttachmentSections +import com.android.messaging.ui.conversation.messages.model.attachment.ConversationMessageAttachment import kotlinx.collections.immutable.ImmutableList @Immutable diff --git a/src/com/android/messaging/ui/conversation/v2/messages/model/message/ConversationMessagePartUiModel.kt b/src/com/android/messaging/ui/conversation/messages/model/message/ConversationMessagePartUiModel.kt similarity index 92% rename from src/com/android/messaging/ui/conversation/v2/messages/model/message/ConversationMessagePartUiModel.kt rename to src/com/android/messaging/ui/conversation/messages/model/message/ConversationMessagePartUiModel.kt index 44a44fab..57911971 100644 --- a/src/com/android/messaging/ui/conversation/v2/messages/model/message/ConversationMessagePartUiModel.kt +++ b/src/com/android/messaging/ui/conversation/messages/model/message/ConversationMessagePartUiModel.kt @@ -1,8 +1,8 @@ -package com.android.messaging.ui.conversation.v2.messages.model.message +package com.android.messaging.ui.conversation.messages.model.message import android.net.Uri import androidx.compose.runtime.Immutable -import com.android.messaging.ui.conversation.v2.attachment.model.ConversationVCardAttachmentUiModel +import com.android.messaging.ui.conversation.attachment.model.ConversationVCardAttachmentUiModel @Immutable internal sealed interface ConversationMessagePartUiModel { diff --git a/src/com/android/messaging/ui/conversation/v2/messages/model/message/ConversationMessageUiModel.kt b/src/com/android/messaging/ui/conversation/messages/model/message/ConversationMessageUiModel.kt similarity index 96% rename from src/com/android/messaging/ui/conversation/v2/messages/model/message/ConversationMessageUiModel.kt rename to src/com/android/messaging/ui/conversation/messages/model/message/ConversationMessageUiModel.kt index 1b65a5fb..d93d016a 100644 --- a/src/com/android/messaging/ui/conversation/v2/messages/model/message/ConversationMessageUiModel.kt +++ b/src/com/android/messaging/ui/conversation/messages/model/message/ConversationMessageUiModel.kt @@ -1,4 +1,4 @@ -package com.android.messaging.ui.conversation.v2.messages.model.message +package com.android.messaging.ui.conversation.messages.model.message import android.net.Uri import androidx.compose.runtime.Immutable diff --git a/src/com/android/messaging/ui/conversation/v2/messages/model/message/ConversationMessagesUiState.kt b/src/com/android/messaging/ui/conversation/messages/model/message/ConversationMessagesUiState.kt similarity index 86% rename from src/com/android/messaging/ui/conversation/v2/messages/model/message/ConversationMessagesUiState.kt rename to src/com/android/messaging/ui/conversation/messages/model/message/ConversationMessagesUiState.kt index 3840b748..9ac1a199 100644 --- a/src/com/android/messaging/ui/conversation/v2/messages/model/message/ConversationMessagesUiState.kt +++ b/src/com/android/messaging/ui/conversation/messages/model/message/ConversationMessagesUiState.kt @@ -1,4 +1,4 @@ -package com.android.messaging.ui.conversation.v2.messages.model.message +package com.android.messaging.ui.conversation.messages.model.message import androidx.compose.runtime.Immutable import kotlinx.collections.immutable.ImmutableList diff --git a/src/com/android/messaging/ui/conversation/v2/messages/model/text/ConversationTextLink.kt b/src/com/android/messaging/ui/conversation/messages/model/text/ConversationTextLink.kt similarity index 69% rename from src/com/android/messaging/ui/conversation/v2/messages/model/text/ConversationTextLink.kt rename to src/com/android/messaging/ui/conversation/messages/model/text/ConversationTextLink.kt index 2c79d721..72de8f97 100644 --- a/src/com/android/messaging/ui/conversation/v2/messages/model/text/ConversationTextLink.kt +++ b/src/com/android/messaging/ui/conversation/messages/model/text/ConversationTextLink.kt @@ -1,4 +1,4 @@ -package com.android.messaging.ui.conversation.v2.messages.model.text +package com.android.messaging.ui.conversation.messages.model.text import androidx.compose.runtime.Immutable diff --git a/src/com/android/messaging/ui/conversation/v2/messages/ui/ConversationMessages.kt b/src/com/android/messaging/ui/conversation/messages/ui/ConversationMessages.kt similarity index 94% rename from src/com/android/messaging/ui/conversation/v2/messages/ui/ConversationMessages.kt rename to src/com/android/messaging/ui/conversation/messages/ui/ConversationMessages.kt index 5f089e0d..9cc3e0bc 100644 --- a/src/com/android/messaging/ui/conversation/v2/messages/ui/ConversationMessages.kt +++ b/src/com/android/messaging/ui/conversation/messages/ui/ConversationMessages.kt @@ -1,4 +1,4 @@ -package com.android.messaging.ui.conversation.v2.messages.ui +package com.android.messaging.ui.conversation.messages.ui import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement @@ -22,12 +22,12 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import com.android.messaging.ui.conversation.v2.CONVERSATION_MESSAGES_LIST_TEST_TAG -import com.android.messaging.ui.conversation.v2.conversationMessageItemTestTag -import com.android.messaging.ui.conversation.v2.messages.model.message.ConversationMessageUiModel -import com.android.messaging.ui.conversation.v2.messages.ui.message.ConversationMessage -import com.android.messaging.ui.conversation.v2.messages.ui.message.conversationMessageDisplayEpochDay -import com.android.messaging.ui.conversation.v2.messages.ui.message.formatDateSeparatorText +import com.android.messaging.ui.conversation.CONVERSATION_MESSAGES_LIST_TEST_TAG +import com.android.messaging.ui.conversation.conversationMessageItemTestTag +import com.android.messaging.ui.conversation.messages.model.message.ConversationMessageUiModel +import com.android.messaging.ui.conversation.messages.ui.message.ConversationMessage +import com.android.messaging.ui.conversation.messages.ui.message.conversationMessageDisplayEpochDay +import com.android.messaging.ui.conversation.messages.ui.message.formatDateSeparatorText import java.util.TimeZone import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableSet diff --git a/src/com/android/messaging/ui/conversation/v2/messages/ui/attachment/ConversationAttachmentActionDispatcher.kt b/src/com/android/messaging/ui/conversation/messages/ui/attachment/ConversationAttachmentActionDispatcher.kt similarity index 83% rename from src/com/android/messaging/ui/conversation/v2/messages/ui/attachment/ConversationAttachmentActionDispatcher.kt rename to src/com/android/messaging/ui/conversation/messages/ui/attachment/ConversationAttachmentActionDispatcher.kt index 7301ca49..0f091060 100644 --- a/src/com/android/messaging/ui/conversation/v2/messages/ui/attachment/ConversationAttachmentActionDispatcher.kt +++ b/src/com/android/messaging/ui/conversation/messages/ui/attachment/ConversationAttachmentActionDispatcher.kt @@ -1,7 +1,7 @@ -package com.android.messaging.ui.conversation.v2.messages.ui.attachment +package com.android.messaging.ui.conversation.messages.ui.attachment -import com.android.messaging.ui.conversation.v2.messages.model.attachment.ConversationAttachmentOpenAction -import com.android.messaging.ui.conversation.v2.messages.model.attachment.ConversationMessageAttachment +import com.android.messaging.ui.conversation.messages.model.attachment.ConversationAttachmentOpenAction +import com.android.messaging.ui.conversation.messages.model.attachment.ConversationMessageAttachment internal fun dispatchConversationAttachmentOpenAction( action: ConversationAttachmentOpenAction, diff --git a/src/com/android/messaging/ui/conversation/v2/messages/ui/attachment/ConversationAttachmentSectionsBuilder.kt b/src/com/android/messaging/ui/conversation/messages/ui/attachment/ConversationAttachmentSectionsBuilder.kt similarity index 88% rename from src/com/android/messaging/ui/conversation/v2/messages/ui/attachment/ConversationAttachmentSectionsBuilder.kt rename to src/com/android/messaging/ui/conversation/messages/ui/attachment/ConversationAttachmentSectionsBuilder.kt index 7ee1ae36..ea5f4ffe 100644 --- a/src/com/android/messaging/ui/conversation/v2/messages/ui/attachment/ConversationAttachmentSectionsBuilder.kt +++ b/src/com/android/messaging/ui/conversation/messages/ui/attachment/ConversationAttachmentSectionsBuilder.kt @@ -1,13 +1,13 @@ -package com.android.messaging.ui.conversation.v2.messages.ui.attachment +package com.android.messaging.ui.conversation.messages.ui.attachment import com.android.messaging.R -import com.android.messaging.ui.conversation.v2.attachment.model.ConversationVCardAttachmentUiModel -import com.android.messaging.ui.conversation.v2.messages.model.attachment.ConversationAttachmentItem -import com.android.messaging.ui.conversation.v2.messages.model.attachment.ConversationAttachmentOpenAction -import com.android.messaging.ui.conversation.v2.messages.model.attachment.ConversationAttachmentSections -import com.android.messaging.ui.conversation.v2.messages.model.attachment.ConversationInlineAttachment -import com.android.messaging.ui.conversation.v2.messages.model.attachment.ConversationMessageAttachment -import com.android.messaging.ui.conversation.v2.messages.model.message.ConversationMessagePartUiModel +import com.android.messaging.ui.conversation.attachment.model.ConversationVCardAttachmentUiModel +import com.android.messaging.ui.conversation.messages.model.attachment.ConversationAttachmentItem +import com.android.messaging.ui.conversation.messages.model.attachment.ConversationAttachmentOpenAction +import com.android.messaging.ui.conversation.messages.model.attachment.ConversationAttachmentSections +import com.android.messaging.ui.conversation.messages.model.attachment.ConversationInlineAttachment +import com.android.messaging.ui.conversation.messages.model.attachment.ConversationMessageAttachment +import com.android.messaging.ui.conversation.messages.model.message.ConversationMessagePartUiModel import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList diff --git a/src/com/android/messaging/ui/conversation/v2/messages/ui/attachment/ConversationGenericInlineAttachmentRow.kt b/src/com/android/messaging/ui/conversation/messages/ui/attachment/ConversationGenericInlineAttachmentRow.kt similarity index 95% rename from src/com/android/messaging/ui/conversation/v2/messages/ui/attachment/ConversationGenericInlineAttachmentRow.kt rename to src/com/android/messaging/ui/conversation/messages/ui/attachment/ConversationGenericInlineAttachmentRow.kt index 6ecdf1f0..0673baba 100644 --- a/src/com/android/messaging/ui/conversation/v2/messages/ui/attachment/ConversationGenericInlineAttachmentRow.kt +++ b/src/com/android/messaging/ui/conversation/messages/ui/attachment/ConversationGenericInlineAttachmentRow.kt @@ -1,4 +1,4 @@ -package com.android.messaging.ui.conversation.v2.messages.ui.attachment +package com.android.messaging.ui.conversation.messages.ui.attachment import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Arrangement @@ -21,7 +21,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp -import com.android.messaging.ui.conversation.v2.messages.model.attachment.ConversationInlineAttachment +import com.android.messaging.ui.conversation.messages.model.attachment.ConversationInlineAttachment @Composable internal fun ConversationGenericInlineAttachmentRow( diff --git a/src/com/android/messaging/ui/conversation/v2/messages/ui/attachment/ConversationInlineAttachmentRow.kt b/src/com/android/messaging/ui/conversation/messages/ui/attachment/ConversationInlineAttachmentRow.kt similarity index 89% rename from src/com/android/messaging/ui/conversation/v2/messages/ui/attachment/ConversationInlineAttachmentRow.kt rename to src/com/android/messaging/ui/conversation/messages/ui/attachment/ConversationInlineAttachmentRow.kt index baedc9f6..f6e86802 100644 --- a/src/com/android/messaging/ui/conversation/v2/messages/ui/attachment/ConversationInlineAttachmentRow.kt +++ b/src/com/android/messaging/ui/conversation/messages/ui/attachment/ConversationInlineAttachmentRow.kt @@ -1,7 +1,7 @@ -package com.android.messaging.ui.conversation.v2.messages.ui.attachment +package com.android.messaging.ui.conversation.messages.ui.attachment import androidx.compose.runtime.Composable -import com.android.messaging.ui.conversation.v2.messages.model.attachment.ConversationInlineAttachment +import com.android.messaging.ui.conversation.messages.model.attachment.ConversationInlineAttachment @Composable internal fun ConversationInlineAttachmentRow( diff --git a/src/com/android/messaging/ui/conversation/v2/messages/ui/attachment/ConversationInlineAudioAttachmentPlaybackState.kt b/src/com/android/messaging/ui/conversation/messages/ui/attachment/ConversationInlineAudioAttachmentPlaybackState.kt similarity index 97% rename from src/com/android/messaging/ui/conversation/v2/messages/ui/attachment/ConversationInlineAudioAttachmentPlaybackState.kt rename to src/com/android/messaging/ui/conversation/messages/ui/attachment/ConversationInlineAudioAttachmentPlaybackState.kt index 51b10239..f1e63944 100644 --- a/src/com/android/messaging/ui/conversation/v2/messages/ui/attachment/ConversationInlineAudioAttachmentPlaybackState.kt +++ b/src/com/android/messaging/ui/conversation/messages/ui/attachment/ConversationInlineAudioAttachmentPlaybackState.kt @@ -1,4 +1,4 @@ -package com.android.messaging.ui.conversation.v2.messages.ui.attachment +package com.android.messaging.ui.conversation.messages.ui.attachment import android.content.Context import android.media.MediaPlayer @@ -13,7 +13,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.core.net.toUri import com.android.messaging.R -import com.android.messaging.ui.conversation.v2.audio.formatConversationAudioDuration +import com.android.messaging.ui.conversation.audio.formatConversationAudioDuration import com.android.messaging.util.UiUtils import kotlin.time.Duration.Companion.milliseconds import kotlinx.coroutines.delay diff --git a/src/com/android/messaging/ui/conversation/v2/messages/ui/attachment/ConversationInlineAudioAttachmentRow.kt b/src/com/android/messaging/ui/conversation/messages/ui/attachment/ConversationInlineAudioAttachmentRow.kt similarity index 96% rename from src/com/android/messaging/ui/conversation/v2/messages/ui/attachment/ConversationInlineAudioAttachmentRow.kt rename to src/com/android/messaging/ui/conversation/messages/ui/attachment/ConversationInlineAudioAttachmentRow.kt index 48a6d392..d0e815ca 100644 --- a/src/com/android/messaging/ui/conversation/v2/messages/ui/attachment/ConversationInlineAudioAttachmentRow.kt +++ b/src/com/android/messaging/ui/conversation/messages/ui/attachment/ConversationInlineAudioAttachmentRow.kt @@ -1,4 +1,4 @@ -package com.android.messaging.ui.conversation.v2.messages.ui.attachment +package com.android.messaging.ui.conversation.messages.ui.attachment import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.animateContentSize @@ -36,9 +36,9 @@ import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.android.messaging.R -import com.android.messaging.ui.conversation.v2.CONVERSATION_INLINE_AUDIO_ATTACHMENT_PLAY_BUTTON_TEST_TAG -import com.android.messaging.ui.conversation.v2.CONVERSATION_INLINE_AUDIO_ATTACHMENT_PROGRESS_TEST_TAG -import com.android.messaging.ui.conversation.v2.messages.model.attachment.ConversationInlineAttachment +import com.android.messaging.ui.conversation.CONVERSATION_INLINE_AUDIO_ATTACHMENT_PLAY_BUTTON_TEST_TAG +import com.android.messaging.ui.conversation.CONVERSATION_INLINE_AUDIO_ATTACHMENT_PROGRESS_TEST_TAG +import com.android.messaging.ui.conversation.messages.model.attachment.ConversationInlineAttachment private val AUDIO_ATTACHMENT_HEIGHT = 70.dp diff --git a/src/com/android/messaging/ui/conversation/v2/messages/ui/attachment/ConversationMessageAttachments.kt b/src/com/android/messaging/ui/conversation/messages/ui/attachment/ConversationMessageAttachments.kt similarity index 91% rename from src/com/android/messaging/ui/conversation/v2/messages/ui/attachment/ConversationMessageAttachments.kt rename to src/com/android/messaging/ui/conversation/messages/ui/attachment/ConversationMessageAttachments.kt index adbabef3..e17e0fe4 100644 --- a/src/com/android/messaging/ui/conversation/v2/messages/ui/attachment/ConversationMessageAttachments.kt +++ b/src/com/android/messaging/ui/conversation/messages/ui/attachment/ConversationMessageAttachments.kt @@ -1,12 +1,12 @@ -package com.android.messaging.ui.conversation.v2.messages.ui.attachment +package com.android.messaging.ui.conversation.messages.ui.attachment import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -import com.android.messaging.ui.conversation.v2.messages.model.attachment.ConversationAttachmentItem -import com.android.messaging.ui.conversation.v2.messages.model.attachment.ConversationAttachmentSections +import com.android.messaging.ui.conversation.messages.model.attachment.ConversationAttachmentItem +import com.android.messaging.ui.conversation.messages.model.attachment.ConversationAttachmentSections @Composable internal fun ConversationMessageAttachments( diff --git a/src/com/android/messaging/ui/conversation/v2/messages/ui/attachment/ConversationVCardInlineAttachmentRow.kt b/src/com/android/messaging/ui/conversation/messages/ui/attachment/ConversationVCardInlineAttachmentRow.kt similarity index 89% rename from src/com/android/messaging/ui/conversation/v2/messages/ui/attachment/ConversationVCardInlineAttachmentRow.kt rename to src/com/android/messaging/ui/conversation/messages/ui/attachment/ConversationVCardInlineAttachmentRow.kt index 323998aa..9051ee0f 100644 --- a/src/com/android/messaging/ui/conversation/v2/messages/ui/attachment/ConversationVCardInlineAttachmentRow.kt +++ b/src/com/android/messaging/ui/conversation/messages/ui/attachment/ConversationVCardInlineAttachmentRow.kt @@ -1,4 +1,4 @@ -package com.android.messaging.ui.conversation.v2.messages.ui.attachment +package com.android.messaging.ui.conversation.messages.ui.attachment import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.fillMaxWidth @@ -9,8 +9,8 @@ import androidx.compose.material3.Surface import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -import com.android.messaging.ui.conversation.v2.attachment.ui.ConversationVCardAttachmentCardContent -import com.android.messaging.ui.conversation.v2.messages.model.attachment.ConversationInlineAttachment +import com.android.messaging.ui.conversation.attachment.ui.ConversationVCardAttachmentCardContent +import com.android.messaging.ui.conversation.messages.model.attachment.ConversationInlineAttachment @Composable internal fun ConversationVCardInlineAttachmentRow( diff --git a/src/com/android/messaging/ui/conversation/v2/messages/ui/attachment/ConversationVisualAttachments.kt b/src/com/android/messaging/ui/conversation/messages/ui/attachment/ConversationVisualAttachments.kt similarity index 97% rename from src/com/android/messaging/ui/conversation/v2/messages/ui/attachment/ConversationVisualAttachments.kt rename to src/com/android/messaging/ui/conversation/messages/ui/attachment/ConversationVisualAttachments.kt index b7409251..ffdba5bf 100644 --- a/src/com/android/messaging/ui/conversation/v2/messages/ui/attachment/ConversationVisualAttachments.kt +++ b/src/com/android/messaging/ui/conversation/messages/ui/attachment/ConversationVisualAttachments.kt @@ -1,4 +1,4 @@ -package com.android.messaging.ui.conversation.v2.messages.ui.attachment +package com.android.messaging.ui.conversation.messages.ui.attachment import androidx.compose.foundation.background import androidx.compose.foundation.combinedClickable @@ -30,9 +30,9 @@ import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp -import com.android.messaging.ui.conversation.v2.attachment.ui.ConversationMediaThumbnail -import com.android.messaging.ui.conversation.v2.messages.model.attachment.ConversationMessageAttachment -import com.android.messaging.ui.conversation.v2.messages.model.message.ConversationMessagePartUiModel +import com.android.messaging.ui.conversation.attachment.ui.ConversationMediaThumbnail +import com.android.messaging.ui.conversation.messages.model.attachment.ConversationMessageAttachment +import com.android.messaging.ui.conversation.messages.model.message.ConversationMessagePartUiModel import com.android.messaging.util.ContentType import kotlinx.collections.immutable.ImmutableList diff --git a/src/com/android/messaging/ui/conversation/v2/messages/ui/message/ConversationMessage.kt b/src/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessage.kt similarity index 97% rename from src/com/android/messaging/ui/conversation/v2/messages/ui/message/ConversationMessage.kt rename to src/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessage.kt index 01753b7d..fcc2dcaf 100644 --- a/src/com/android/messaging/ui/conversation/v2/messages/ui/message/ConversationMessage.kt +++ b/src/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessage.kt @@ -1,4 +1,4 @@ -package com.android.messaging.ui.conversation.v2.messages.ui.message +package com.android.messaging.ui.conversation.messages.ui.message import android.content.Context import android.text.format.DateUtils @@ -28,9 +28,9 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.android.messaging.R import com.android.messaging.sms.cleanseMmsSubject -import com.android.messaging.ui.conversation.v2.messages.model.message.ConversationMessageContent -import com.android.messaging.ui.conversation.v2.messages.model.message.ConversationMessageUiModel -import com.android.messaging.ui.conversation.v2.messages.model.message.ConversationMessageUiModel.Status +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 private const val MESSAGE_BUBBLE_MAX_WIDTH_DP = 360 private const val MESSAGE_BUBBLE_WIDTH_FRACTION = 0.8f diff --git a/src/com/android/messaging/ui/conversation/v2/messages/ui/message/ConversationMessageBubble.kt b/src/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessageBubble.kt similarity index 97% rename from src/com/android/messaging/ui/conversation/v2/messages/ui/message/ConversationMessageBubble.kt rename to src/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessageBubble.kt index 60e915b0..73373451 100644 --- a/src/com/android/messaging/ui/conversation/v2/messages/ui/message/ConversationMessageBubble.kt +++ b/src/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessageBubble.kt @@ -1,4 +1,4 @@ -package com.android.messaging.ui.conversation.v2.messages.ui.message +package com.android.messaging.ui.conversation.messages.ui.message import androidx.compose.animation.animateColorAsState import androidx.compose.foundation.background @@ -21,10 +21,10 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import com.android.messaging.ui.conversation.v2.messages.model.message.ConversationMessageContent -import com.android.messaging.ui.conversation.v2.messages.model.message.ConversationMessageUiModel -import com.android.messaging.ui.conversation.v2.messages.ui.attachment.ConversationMessageAttachments -import com.android.messaging.ui.conversation.v2.messages.ui.text.ConversationMessageText +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 +import com.android.messaging.ui.conversation.messages.ui.text.ConversationMessageText private val MESSAGE_BUBBLE_MEDIA_SECTION_SPACING = 8.dp private val MESSAGE_BUBBLE_MEDIA_TEXT_PADDING = 12.dp diff --git a/src/com/android/messaging/ui/conversation/v2/messages/ui/message/ConversationMessageContentBuilder.kt b/src/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessageContentBuilder.kt similarity index 89% rename from src/com/android/messaging/ui/conversation/v2/messages/ui/message/ConversationMessageContentBuilder.kt rename to src/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessageContentBuilder.kt index 63c601b3..15d69169 100644 --- a/src/com/android/messaging/ui/conversation/v2/messages/ui/message/ConversationMessageContentBuilder.kt +++ b/src/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessageContentBuilder.kt @@ -1,13 +1,13 @@ -package com.android.messaging.ui.conversation.v2.messages.ui.message +package com.android.messaging.ui.conversation.messages.ui.message import android.net.Uri import android.util.Patterns import android.webkit.URLUtil -import com.android.messaging.ui.conversation.v2.messages.model.attachment.ConversationMessageAttachment -import com.android.messaging.ui.conversation.v2.messages.model.message.ConversationMessageContent -import com.android.messaging.ui.conversation.v2.messages.model.message.ConversationMessagePartUiModel -import com.android.messaging.ui.conversation.v2.messages.model.message.ConversationMessageUiModel -import com.android.messaging.ui.conversation.v2.messages.ui.attachment.buildConversationAttachmentSections +import com.android.messaging.ui.conversation.messages.model.attachment.ConversationMessageAttachment +import com.android.messaging.ui.conversation.messages.model.message.ConversationMessageContent +import com.android.messaging.ui.conversation.messages.model.message.ConversationMessagePartUiModel +import com.android.messaging.ui.conversation.messages.model.message.ConversationMessageUiModel +import com.android.messaging.ui.conversation.messages.ui.attachment.buildConversationAttachmentSections import com.android.messaging.util.YouTubeUtil import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList diff --git a/src/com/android/messaging/ui/conversation/v2/messages/ui/message/ConversationMessageDateFormatting.kt b/src/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessageDateFormatting.kt similarity index 91% rename from src/com/android/messaging/ui/conversation/v2/messages/ui/message/ConversationMessageDateFormatting.kt rename to src/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessageDateFormatting.kt index 8a540403..3d4a7448 100644 --- a/src/com/android/messaging/ui/conversation/v2/messages/ui/message/ConversationMessageDateFormatting.kt +++ b/src/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessageDateFormatting.kt @@ -1,8 +1,8 @@ -package com.android.messaging.ui.conversation.v2.messages.ui.message +package com.android.messaging.ui.conversation.messages.ui.message import android.content.Context import android.text.format.DateUtils -import com.android.messaging.ui.conversation.v2.messages.model.message.ConversationMessageUiModel +import com.android.messaging.ui.conversation.messages.model.message.ConversationMessageUiModel import java.time.Instant import java.time.LocalDate import java.time.ZoneId diff --git a/src/com/android/messaging/ui/conversation/v2/messages/ui/message/ConversationMessageMetadata.kt b/src/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessageMetadata.kt similarity index 84% rename from src/com/android/messaging/ui/conversation/v2/messages/ui/message/ConversationMessageMetadata.kt rename to src/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessageMetadata.kt index 822a478e..dbc0dfd0 100644 --- a/src/com/android/messaging/ui/conversation/v2/messages/ui/message/ConversationMessageMetadata.kt +++ b/src/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessageMetadata.kt @@ -1,4 +1,4 @@ -package com.android.messaging.ui.conversation.v2.messages.ui.message +package com.android.messaging.ui.conversation.messages.ui.message import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding @@ -9,8 +9,8 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp -import com.android.messaging.ui.conversation.v2.messages.model.message.ConversationMessageUiModel -import com.android.messaging.ui.conversation.v2.messages.model.message.ConversationMessageUiModel.Status +import com.android.messaging.ui.conversation.messages.model.message.ConversationMessageUiModel +import com.android.messaging.ui.conversation.messages.model.message.ConversationMessageUiModel.Status @Composable internal fun ConversationMessageMetadata( diff --git a/src/com/android/messaging/ui/conversation/v2/messages/ui/text/ConversationMessageText.kt b/src/com/android/messaging/ui/conversation/messages/ui/text/ConversationMessageText.kt similarity index 95% rename from src/com/android/messaging/ui/conversation/v2/messages/ui/text/ConversationMessageText.kt rename to src/com/android/messaging/ui/conversation/messages/ui/text/ConversationMessageText.kt index 751d4578..b6e38240 100644 --- a/src/com/android/messaging/ui/conversation/v2/messages/ui/text/ConversationMessageText.kt +++ b/src/com/android/messaging/ui/conversation/messages/ui/text/ConversationMessageText.kt @@ -1,4 +1,4 @@ -package com.android.messaging.ui.conversation.v2.messages.ui.text +package com.android.messaging.ui.conversation.messages.ui.text import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -16,7 +16,7 @@ import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.withLink -import com.android.messaging.ui.conversation.v2.messages.model.text.ConversationTextLink +import com.android.messaging.ui.conversation.messages.model.text.ConversationTextLink import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext diff --git a/src/com/android/messaging/ui/conversation/v2/messages/ui/text/ConversationMessageTextLinkExtractor.kt b/src/com/android/messaging/ui/conversation/messages/ui/text/ConversationMessageTextLinkExtractor.kt similarity index 95% rename from src/com/android/messaging/ui/conversation/v2/messages/ui/text/ConversationMessageTextLinkExtractor.kt rename to src/com/android/messaging/ui/conversation/messages/ui/text/ConversationMessageTextLinkExtractor.kt index 3b969c67..8cbcf0f3 100644 --- a/src/com/android/messaging/ui/conversation/v2/messages/ui/text/ConversationMessageTextLinkExtractor.kt +++ b/src/com/android/messaging/ui/conversation/messages/ui/text/ConversationMessageTextLinkExtractor.kt @@ -1,4 +1,4 @@ -package com.android.messaging.ui.conversation.v2.messages.ui.text +package com.android.messaging.ui.conversation.messages.ui.text import android.content.Context import android.net.Uri @@ -6,7 +6,7 @@ import android.view.textclassifier.TextClassificationManager import android.view.textclassifier.TextClassifier import android.view.textclassifier.TextLinks import android.webkit.URLUtil -import com.android.messaging.ui.conversation.v2.messages.model.text.ConversationTextLink +import com.android.messaging.ui.conversation.messages.model.text.ConversationTextLink private data class ConversationLinkText( val start: Int, diff --git a/src/com/android/messaging/ui/conversation/v2/metadata/delegate/ConversationMetadataDelegate.kt b/src/com/android/messaging/ui/conversation/metadata/delegate/ConversationMetadataDelegate.kt similarity index 92% rename from src/com/android/messaging/ui/conversation/v2/metadata/delegate/ConversationMetadataDelegate.kt rename to src/com/android/messaging/ui/conversation/metadata/delegate/ConversationMetadataDelegate.kt index b455f9e9..a5164feb 100644 --- a/src/com/android/messaging/ui/conversation/v2/metadata/delegate/ConversationMetadataDelegate.kt +++ b/src/com/android/messaging/ui/conversation/metadata/delegate/ConversationMetadataDelegate.kt @@ -1,11 +1,11 @@ -package com.android.messaging.ui.conversation.v2.metadata.delegate +package com.android.messaging.ui.conversation.metadata.delegate import com.android.messaging.data.conversation.repository.ConversationsRepository import com.android.messaging.di.core.DefaultDispatcher -import com.android.messaging.ui.conversation.v2.common.ConversationScreenDelegate -import com.android.messaging.ui.conversation.v2.metadata.mapper.ConversationMetadataUiStateMapper -import com.android.messaging.ui.conversation.v2.metadata.model.ConversationMetadataUiState -import com.android.messaging.ui.conversation.v2.screen.model.ConversationScreenEffect +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.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope diff --git a/src/com/android/messaging/ui/conversation/v2/metadata/mapper/ConversationMetadataUiStateMapper.kt b/src/com/android/messaging/ui/conversation/metadata/mapper/ConversationMetadataUiStateMapper.kt similarity index 90% rename from src/com/android/messaging/ui/conversation/v2/metadata/mapper/ConversationMetadataUiStateMapper.kt rename to src/com/android/messaging/ui/conversation/metadata/mapper/ConversationMetadataUiStateMapper.kt index ab712ca6..60d82f4a 100644 --- a/src/com/android/messaging/ui/conversation/v2/metadata/mapper/ConversationMetadataUiStateMapper.kt +++ b/src/com/android/messaging/ui/conversation/metadata/mapper/ConversationMetadataUiStateMapper.kt @@ -1,8 +1,8 @@ -package com.android.messaging.ui.conversation.v2.metadata.mapper +package com.android.messaging.ui.conversation.metadata.mapper import com.android.messaging.data.conversation.model.metadata.ConversationMetadata import com.android.messaging.sms.MmsSmsUtils -import com.android.messaging.ui.conversation.v2.metadata.model.ConversationMetadataUiState +import com.android.messaging.ui.conversation.metadata.model.ConversationMetadataUiState import javax.inject.Inject internal interface ConversationMetadataUiStateMapper { diff --git a/src/com/android/messaging/ui/conversation/v2/metadata/model/ConversationMetadataUiState.kt b/src/com/android/messaging/ui/conversation/metadata/model/ConversationMetadataUiState.kt similarity index 96% rename from src/com/android/messaging/ui/conversation/v2/metadata/model/ConversationMetadataUiState.kt rename to src/com/android/messaging/ui/conversation/metadata/model/ConversationMetadataUiState.kt index e5469ef1..50d1e6c4 100644 --- a/src/com/android/messaging/ui/conversation/v2/metadata/model/ConversationMetadataUiState.kt +++ b/src/com/android/messaging/ui/conversation/metadata/model/ConversationMetadataUiState.kt @@ -1,4 +1,4 @@ -package com.android.messaging.ui.conversation.v2.metadata.model +package com.android.messaging.ui.conversation.metadata.model import androidx.compose.runtime.Immutable import com.android.messaging.data.conversation.model.metadata.ConversationComposerAvailability diff --git a/src/com/android/messaging/ui/conversation/v2/metadata/ui/ConversationTopAppBar.kt b/src/com/android/messaging/ui/conversation/metadata/ui/ConversationTopAppBar.kt similarity index 95% rename from src/com/android/messaging/ui/conversation/v2/metadata/ui/ConversationTopAppBar.kt rename to src/com/android/messaging/ui/conversation/metadata/ui/ConversationTopAppBar.kt index cbc11ed3..97ec8d93 100644 --- a/src/com/android/messaging/ui/conversation/v2/metadata/ui/ConversationTopAppBar.kt +++ b/src/com/android/messaging/ui/conversation/metadata/ui/ConversationTopAppBar.kt @@ -1,4 +1,4 @@ -package com.android.messaging.ui.conversation.v2.metadata.ui +package com.android.messaging.ui.conversation.metadata.ui import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -55,17 +55,17 @@ import androidx.core.text.BidiFormatter import androidx.core.text.TextDirectionHeuristicsCompat import coil3.compose.AsyncImage import com.android.messaging.R -import com.android.messaging.ui.conversation.v2.CONVERSATION_ADD_CONTACT_BUTTON_TEST_TAG -import com.android.messaging.ui.conversation.v2.CONVERSATION_ADD_PEOPLE_BUTTON_TEST_TAG -import com.android.messaging.ui.conversation.v2.CONVERSATION_ARCHIVE_BUTTON_TEST_TAG -import com.android.messaging.ui.conversation.v2.CONVERSATION_CALL_BUTTON_TEST_TAG -import com.android.messaging.ui.conversation.v2.CONVERSATION_DELETE_CONVERSATION_BUTTON_TEST_TAG -import com.android.messaging.ui.conversation.v2.CONVERSATION_OVERFLOW_BUTTON_TEST_TAG -import com.android.messaging.ui.conversation.v2.CONVERSATION_SIM_SELECTOR_MENU_ITEM_TEST_TAG -import com.android.messaging.ui.conversation.v2.CONVERSATION_UNARCHIVE_BUTTON_TEST_TAG -import com.android.messaging.ui.conversation.v2.composer.model.ConversationSimSelectorUiState -import com.android.messaging.ui.conversation.v2.metadata.model.ConversationMetadataUiState -import com.android.messaging.ui.conversation.v2.resolveDisplayName +import com.android.messaging.ui.conversation.CONVERSATION_ADD_CONTACT_BUTTON_TEST_TAG +import com.android.messaging.ui.conversation.CONVERSATION_ADD_PEOPLE_BUTTON_TEST_TAG +import com.android.messaging.ui.conversation.CONVERSATION_ARCHIVE_BUTTON_TEST_TAG +import com.android.messaging.ui.conversation.CONVERSATION_CALL_BUTTON_TEST_TAG +import com.android.messaging.ui.conversation.CONVERSATION_DELETE_CONVERSATION_BUTTON_TEST_TAG +import com.android.messaging.ui.conversation.CONVERSATION_OVERFLOW_BUTTON_TEST_TAG +import com.android.messaging.ui.conversation.CONVERSATION_SIM_SELECTOR_MENU_ITEM_TEST_TAG +import com.android.messaging.ui.conversation.CONVERSATION_UNARCHIVE_BUTTON_TEST_TAG +import com.android.messaging.ui.conversation.composer.model.ConversationSimSelectorUiState +import com.android.messaging.ui.conversation.metadata.model.ConversationMetadataUiState +import com.android.messaging.ui.conversation.resolveDisplayName import com.android.messaging.util.AccessibilityUtil private val CONVERSATION_TOP_APP_BAR_TITLE_SPACING = 12.dp diff --git a/src/com/android/messaging/ui/conversation/v2/navigation/ConversationNavGraph.kt b/src/com/android/messaging/ui/conversation/navigation/ConversationNavGraph.kt similarity index 94% rename from src/com/android/messaging/ui/conversation/v2/navigation/ConversationNavGraph.kt rename to src/com/android/messaging/ui/conversation/navigation/ConversationNavGraph.kt index 160093f3..4735dcd5 100644 --- a/src/com/android/messaging/ui/conversation/v2/navigation/ConversationNavGraph.kt +++ b/src/com/android/messaging/ui/conversation/navigation/ConversationNavGraph.kt @@ -1,4 +1,4 @@ -package com.android.messaging.ui.conversation.v2.navigation +package com.android.messaging.ui.conversation.navigation import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable @@ -18,16 +18,16 @@ import androidx.navigation3.runtime.rememberNavBackStack import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator import androidx.navigation3.ui.NavDisplay import com.android.messaging.data.conversation.model.draft.ConversationDraft -import com.android.messaging.ui.conversation.v2.addparticipants.AddParticipantsScreen -import com.android.messaging.ui.conversation.v2.entry.ConversationEntryScreenModel -import com.android.messaging.ui.conversation.v2.entry.ConversationEntryViewModel -import com.android.messaging.ui.conversation.v2.entry.NewChatScreen -import com.android.messaging.ui.conversation.v2.entry.model.ConversationEntryEffect -import com.android.messaging.ui.conversation.v2.entry.model.ConversationEntryLaunchRequest -import com.android.messaging.ui.conversation.v2.entry.model.ConversationEntryStartupAttachment -import com.android.messaging.ui.conversation.v2.entry.model.ConversationEntryUiState -import com.android.messaging.ui.conversation.v2.recipientpicker.RecipientPickerScreen -import com.android.messaging.ui.conversation.v2.screen.ConversationScreen +import com.android.messaging.ui.conversation.addparticipants.AddParticipantsScreen +import com.android.messaging.ui.conversation.entry.ConversationEntryScreenModel +import com.android.messaging.ui.conversation.entry.ConversationEntryViewModel +import com.android.messaging.ui.conversation.entry.NewChatScreen +import com.android.messaging.ui.conversation.entry.model.ConversationEntryEffect +import com.android.messaging.ui.conversation.entry.model.ConversationEntryLaunchRequest +import com.android.messaging.ui.conversation.entry.model.ConversationEntryStartupAttachment +import com.android.messaging.ui.conversation.entry.model.ConversationEntryUiState +import com.android.messaging.ui.conversation.recipientpicker.RecipientPickerScreen +import com.android.messaging.ui.conversation.screen.ConversationScreen import com.android.messaging.util.UiUtils @Composable diff --git a/src/com/android/messaging/ui/conversation/v2/navigation/ConversationNavKey.kt b/src/com/android/messaging/ui/conversation/navigation/ConversationNavKey.kt similarity index 90% rename from src/com/android/messaging/ui/conversation/v2/navigation/ConversationNavKey.kt rename to src/com/android/messaging/ui/conversation/navigation/ConversationNavKey.kt index 1b3531b5..2900c53b 100644 --- a/src/com/android/messaging/ui/conversation/v2/navigation/ConversationNavKey.kt +++ b/src/com/android/messaging/ui/conversation/navigation/ConversationNavKey.kt @@ -1,4 +1,4 @@ -package com.android.messaging.ui.conversation.v2.navigation +package com.android.messaging.ui.conversation.navigation import androidx.navigation3.runtime.NavKey import kotlinx.serialization.Serializable diff --git a/src/com/android/messaging/ui/conversation/v2/navigation/ConversationNavigationReducer.kt b/src/com/android/messaging/ui/conversation/navigation/ConversationNavigationReducer.kt similarity index 98% rename from src/com/android/messaging/ui/conversation/v2/navigation/ConversationNavigationReducer.kt rename to src/com/android/messaging/ui/conversation/navigation/ConversationNavigationReducer.kt index 37c9ac69..901b1a08 100644 --- a/src/com/android/messaging/ui/conversation/v2/navigation/ConversationNavigationReducer.kt +++ b/src/com/android/messaging/ui/conversation/navigation/ConversationNavigationReducer.kt @@ -1,4 +1,4 @@ -package com.android.messaging.ui.conversation.v2.navigation +package com.android.messaging.ui.conversation.navigation import androidx.navigation3.runtime.NavKey diff --git a/src/com/android/messaging/ui/conversation/v2/recipientpicker/RecipientPickerScreen.kt b/src/com/android/messaging/ui/conversation/recipientpicker/RecipientPickerScreen.kt similarity index 91% rename from src/com/android/messaging/ui/conversation/v2/recipientpicker/RecipientPickerScreen.kt rename to src/com/android/messaging/ui/conversation/recipientpicker/RecipientPickerScreen.kt index c34da132..37c83156 100644 --- a/src/com/android/messaging/ui/conversation/v2/recipientpicker/RecipientPickerScreen.kt +++ b/src/com/android/messaging/ui/conversation/recipientpicker/RecipientPickerScreen.kt @@ -1,6 +1,6 @@ @file:OptIn(ExperimentalMaterial3Api::class) -package com.android.messaging.ui.conversation.v2.recipientpicker +package com.android.messaging.ui.conversation.recipientpicker import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize @@ -14,7 +14,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import com.android.messaging.R -import com.android.messaging.ui.conversation.v2.navigation.RecipientPickerMode +import com.android.messaging.ui.conversation.navigation.RecipientPickerMode @Composable internal fun RecipientPickerScreen( diff --git a/src/com/android/messaging/ui/conversation/v2/recipientpicker/RecipientPickerViewModel.kt b/src/com/android/messaging/ui/conversation/recipientpicker/RecipientPickerViewModel.kt similarity index 81% rename from src/com/android/messaging/ui/conversation/v2/recipientpicker/RecipientPickerViewModel.kt rename to src/com/android/messaging/ui/conversation/recipientpicker/RecipientPickerViewModel.kt index 9a114ee4..8adb8e26 100644 --- a/src/com/android/messaging/ui/conversation/v2/recipientpicker/RecipientPickerViewModel.kt +++ b/src/com/android/messaging/ui/conversation/recipientpicker/RecipientPickerViewModel.kt @@ -1,9 +1,9 @@ -package com.android.messaging.ui.conversation.v2.recipientpicker +package com.android.messaging.ui.conversation.recipientpicker import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.android.messaging.ui.conversation.v2.recipientpicker.delegate.RecipientPickerDelegate -import com.android.messaging.ui.conversation.v2.recipientpicker.model.RecipientPickerUiState +import com.android.messaging.ui.conversation.recipientpicker.delegate.RecipientPickerDelegate +import com.android.messaging.ui.conversation.recipientpicker.model.RecipientPickerUiState import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject import kotlinx.coroutines.flow.StateFlow diff --git a/src/com/android/messaging/ui/conversation/v2/recipientpicker/RecipientSelectionContactAvatar.kt b/src/com/android/messaging/ui/conversation/recipientpicker/RecipientSelectionContactAvatar.kt similarity index 97% rename from src/com/android/messaging/ui/conversation/v2/recipientpicker/RecipientSelectionContactAvatar.kt rename to src/com/android/messaging/ui/conversation/recipientpicker/RecipientSelectionContactAvatar.kt index adbba6d1..9772e71c 100644 --- a/src/com/android/messaging/ui/conversation/v2/recipientpicker/RecipientSelectionContactAvatar.kt +++ b/src/com/android/messaging/ui/conversation/recipientpicker/RecipientSelectionContactAvatar.kt @@ -1,4 +1,4 @@ -package com.android.messaging.ui.conversation.v2.recipientpicker +package com.android.messaging.ui.conversation.recipientpicker import androidx.compose.animation.AnimatedContent import androidx.compose.animation.core.Spring @@ -32,7 +32,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage import com.android.messaging.R -import com.android.messaging.ui.conversation.v2.recipientpicker.model.RecipientPickerListItem +import com.android.messaging.ui.conversation.recipientpicker.model.RecipientPickerListItem @Composable internal fun RecipientSelectionContactAvatar( diff --git a/src/com/android/messaging/ui/conversation/v2/recipientpicker/RecipientSelectionContactRow.kt b/src/com/android/messaging/ui/conversation/recipientpicker/RecipientSelectionContactRow.kt similarity index 98% rename from src/com/android/messaging/ui/conversation/v2/recipientpicker/RecipientSelectionContactRow.kt rename to src/com/android/messaging/ui/conversation/recipientpicker/RecipientSelectionContactRow.kt index 95e84038..a6a7d6b1 100644 --- a/src/com/android/messaging/ui/conversation/v2/recipientpicker/RecipientSelectionContactRow.kt +++ b/src/com/android/messaging/ui/conversation/recipientpicker/RecipientSelectionContactRow.kt @@ -1,4 +1,4 @@ -package com.android.messaging.ui.conversation.v2.recipientpicker +package com.android.messaging.ui.conversation.recipientpicker import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.animateColor @@ -38,7 +38,7 @@ import androidx.compose.ui.semantics.selected import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import com.android.messaging.ui.conversation.v2.recipientpicker.model.RecipientPickerListItem +import com.android.messaging.ui.conversation.recipientpicker.model.RecipientPickerListItem private val contactCornerRadius = 18.dp private val contactMiddleCornerRadius = 2.dp diff --git a/src/com/android/messaging/ui/conversation/v2/recipientpicker/RecipientSelectionContactsContent.kt b/src/com/android/messaging/ui/conversation/recipientpicker/RecipientSelectionContactsContent.kt similarity index 97% rename from src/com/android/messaging/ui/conversation/v2/recipientpicker/RecipientSelectionContactsContent.kt rename to src/com/android/messaging/ui/conversation/recipientpicker/RecipientSelectionContactsContent.kt index c8a04636..64807134 100644 --- a/src/com/android/messaging/ui/conversation/v2/recipientpicker/RecipientSelectionContactsContent.kt +++ b/src/com/android/messaging/ui/conversation/recipientpicker/RecipientSelectionContactsContent.kt @@ -1,4 +1,4 @@ -package com.android.messaging.ui.conversation.v2.recipientpicker +package com.android.messaging.ui.conversation.recipientpicker import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.EnterTransition @@ -37,8 +37,8 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.android.messaging.R -import com.android.messaging.ui.conversation.v2.recipientpicker.model.RecipientPickerListItem -import com.android.messaging.ui.conversation.v2.recipientpicker.model.RecipientPickerUiState +import com.android.messaging.ui.conversation.recipientpicker.model.RecipientPickerListItem +import com.android.messaging.ui.conversation.recipientpicker.model.RecipientPickerUiState private const val CONTACTS_LOAD_MORE_THRESHOLD = 10 private const val RECIPIENT_CONTACT_CONTENT_TYPE = "recipient_contact" diff --git a/src/com/android/messaging/ui/conversation/v2/recipientpicker/RecipientSelectionContent.kt b/src/com/android/messaging/ui/conversation/recipientpicker/RecipientSelectionContent.kt similarity index 96% rename from src/com/android/messaging/ui/conversation/v2/recipientpicker/RecipientSelectionContent.kt rename to src/com/android/messaging/ui/conversation/recipientpicker/RecipientSelectionContent.kt index 13f575a8..09a57c07 100644 --- a/src/com/android/messaging/ui/conversation/v2/recipientpicker/RecipientSelectionContent.kt +++ b/src/com/android/messaging/ui/conversation/recipientpicker/RecipientSelectionContent.kt @@ -2,7 +2,7 @@ ExperimentalMaterial3Api::class, ) -package com.android.messaging.ui.conversation.v2.recipientpicker +package com.android.messaging.ui.conversation.recipientpicker import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer @@ -21,7 +21,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp -import com.android.messaging.ui.conversation.v2.recipientpicker.model.RecipientPickerListItem +import com.android.messaging.ui.conversation.recipientpicker.model.RecipientPickerListItem private val searchFieldShape = RoundedCornerShape(size = 22.dp) diff --git a/src/com/android/messaging/ui/conversation/v2/recipientpicker/RecipientSelectionContentUiState.kt b/src/com/android/messaging/ui/conversation/recipientpicker/RecipientSelectionContentUiState.kt similarity index 80% rename from src/com/android/messaging/ui/conversation/v2/recipientpicker/RecipientSelectionContentUiState.kt rename to src/com/android/messaging/ui/conversation/recipientpicker/RecipientSelectionContentUiState.kt index 6de1bcae..d98a3cab 100644 --- a/src/com/android/messaging/ui/conversation/v2/recipientpicker/RecipientSelectionContentUiState.kt +++ b/src/com/android/messaging/ui/conversation/recipientpicker/RecipientSelectionContentUiState.kt @@ -1,8 +1,8 @@ -package com.android.messaging.ui.conversation.v2.recipientpicker +package com.android.messaging.ui.conversation.recipientpicker import androidx.compose.runtime.Immutable -import com.android.messaging.ui.conversation.v2.recipientpicker.model.RecipientPickerListItem -import com.android.messaging.ui.conversation.v2.recipientpicker.model.RecipientPickerUiState +import com.android.messaging.ui.conversation.recipientpicker.model.RecipientPickerListItem +import com.android.messaging.ui.conversation.recipientpicker.model.RecipientPickerUiState import kotlinx.collections.immutable.ImmutableSet import kotlinx.collections.immutable.persistentSetOf diff --git a/src/com/android/messaging/ui/conversation/v2/recipientpicker/RecipientSelectionPrimaryActionButton.kt b/src/com/android/messaging/ui/conversation/recipientpicker/RecipientSelectionPrimaryActionButton.kt similarity index 98% rename from src/com/android/messaging/ui/conversation/v2/recipientpicker/RecipientSelectionPrimaryActionButton.kt rename to src/com/android/messaging/ui/conversation/recipientpicker/RecipientSelectionPrimaryActionButton.kt index 6a03c679..33d1f7f2 100644 --- a/src/com/android/messaging/ui/conversation/v2/recipientpicker/RecipientSelectionPrimaryActionButton.kt +++ b/src/com/android/messaging/ui/conversation/recipientpicker/RecipientSelectionPrimaryActionButton.kt @@ -1,4 +1,4 @@ -package com.android.messaging.ui.conversation.v2.recipientpicker +package com.android.messaging.ui.conversation.recipientpicker import androidx.compose.animation.AnimatedContent import androidx.compose.animation.ContentTransform diff --git a/src/com/android/messaging/ui/conversation/v2/recipientpicker/delegate/RecipientPickerDelegate.kt b/src/com/android/messaging/ui/conversation/recipientpicker/delegate/RecipientPickerDelegate.kt similarity index 98% rename from src/com/android/messaging/ui/conversation/v2/recipientpicker/delegate/RecipientPickerDelegate.kt rename to src/com/android/messaging/ui/conversation/recipientpicker/delegate/RecipientPickerDelegate.kt index 091305a9..845c50f4 100644 --- a/src/com/android/messaging/ui/conversation/v2/recipientpicker/delegate/RecipientPickerDelegate.kt +++ b/src/com/android/messaging/ui/conversation/recipientpicker/delegate/RecipientPickerDelegate.kt @@ -1,4 +1,4 @@ -package com.android.messaging.ui.conversation.v2.recipientpicker.delegate +package com.android.messaging.ui.conversation.recipientpicker.delegate import androidx.lifecycle.SavedStateHandle import com.android.messaging.data.conversation.model.recipient.ConversationRecipient @@ -7,8 +7,8 @@ import com.android.messaging.data.conversation.repository.ConversationRecipients import com.android.messaging.di.core.DefaultDispatcher import com.android.messaging.domain.contacts.usecase.IsReadContactsPermissionGranted import com.android.messaging.sms.MmsSmsUtils -import com.android.messaging.ui.conversation.v2.recipientpicker.model.RecipientPickerListItem -import com.android.messaging.ui.conversation.v2.recipientpicker.model.RecipientPickerUiState +import com.android.messaging.ui.conversation.recipientpicker.model.RecipientPickerListItem +import com.android.messaging.ui.conversation.recipientpicker.model.RecipientPickerUiState import com.android.messaging.util.PhoneUtils import javax.inject.Inject import kotlinx.collections.immutable.ImmutableList diff --git a/src/com/android/messaging/ui/conversation/v2/recipientpicker/model/RecipientPickerListItem.kt b/src/com/android/messaging/ui/conversation/recipientpicker/model/RecipientPickerListItem.kt similarity index 92% rename from src/com/android/messaging/ui/conversation/v2/recipientpicker/model/RecipientPickerListItem.kt rename to src/com/android/messaging/ui/conversation/recipientpicker/model/RecipientPickerListItem.kt index 26941f5b..a0729dda 100644 --- a/src/com/android/messaging/ui/conversation/v2/recipientpicker/model/RecipientPickerListItem.kt +++ b/src/com/android/messaging/ui/conversation/recipientpicker/model/RecipientPickerListItem.kt @@ -1,4 +1,4 @@ -package com.android.messaging.ui.conversation.v2.recipientpicker.model +package com.android.messaging.ui.conversation.recipientpicker.model import androidx.compose.runtime.Immutable import com.android.messaging.data.conversation.model.recipient.ConversationRecipient diff --git a/src/com/android/messaging/ui/conversation/v2/recipientpicker/model/RecipientPickerUiState.kt b/src/com/android/messaging/ui/conversation/recipientpicker/model/RecipientPickerUiState.kt similarity index 86% rename from src/com/android/messaging/ui/conversation/v2/recipientpicker/model/RecipientPickerUiState.kt rename to src/com/android/messaging/ui/conversation/recipientpicker/model/RecipientPickerUiState.kt index 7b2c0f7e..63f7bf1e 100644 --- a/src/com/android/messaging/ui/conversation/v2/recipientpicker/model/RecipientPickerUiState.kt +++ b/src/com/android/messaging/ui/conversation/recipientpicker/model/RecipientPickerUiState.kt @@ -1,4 +1,4 @@ -package com.android.messaging.ui.conversation.v2.recipientpicker.model +package com.android.messaging.ui.conversation.recipientpicker.model import androidx.compose.runtime.Immutable import kotlinx.collections.immutable.ImmutableList diff --git a/src/com/android/messaging/ui/conversation/v2/screen/ConversationAutoScrollPolicy.kt b/src/com/android/messaging/ui/conversation/screen/ConversationAutoScrollPolicy.kt similarity index 96% rename from src/com/android/messaging/ui/conversation/v2/screen/ConversationAutoScrollPolicy.kt rename to src/com/android/messaging/ui/conversation/screen/ConversationAutoScrollPolicy.kt index db2e4dff..37dfe4d1 100644 --- a/src/com/android/messaging/ui/conversation/v2/screen/ConversationAutoScrollPolicy.kt +++ b/src/com/android/messaging/ui/conversation/screen/ConversationAutoScrollPolicy.kt @@ -1,4 +1,4 @@ -package com.android.messaging.ui.conversation.v2.screen +package com.android.messaging.ui.conversation.screen internal data class ConversationAutoScrollInput( val previousLatestMessageId: String?, diff --git a/src/com/android/messaging/ui/conversation/v2/screen/ConversationScreen.kt b/src/com/android/messaging/ui/conversation/screen/ConversationScreen.kt similarity index 94% rename from src/com/android/messaging/ui/conversation/v2/screen/ConversationScreen.kt rename to src/com/android/messaging/ui/conversation/screen/ConversationScreen.kt index ebca1f2e..1adf19b8 100644 --- a/src/com/android/messaging/ui/conversation/v2/screen/ConversationScreen.kt +++ b/src/com/android/messaging/ui/conversation/screen/ConversationScreen.kt @@ -1,4 +1,4 @@ -package com.android.messaging.ui.conversation.v2.screen +package com.android.messaging.ui.conversation.screen import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.PaddingValues @@ -33,18 +33,18 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.android.messaging.R import com.android.messaging.data.conversation.model.draft.ConversationDraft -import com.android.messaging.ui.conversation.v2.CONVERSATION_LOADING_INDICATOR_TEST_TAG -import com.android.messaging.ui.conversation.v2.composer.ui.ConversationComposerSection -import com.android.messaging.ui.conversation.v2.composer.ui.ConversationSimSelectorSheet -import com.android.messaging.ui.conversation.v2.entry.model.ConversationEntryStartupAttachment -import com.android.messaging.ui.conversation.v2.mediapicker.rememberConversationMediaPickerPermissionState -import com.android.messaging.ui.conversation.v2.mediapicker.rememberConversationMediaPickerState -import com.android.messaging.ui.conversation.v2.messages.model.message.ConversationMessageUiModel -import com.android.messaging.ui.conversation.v2.messages.model.message.ConversationMessagesUiState -import com.android.messaging.ui.conversation.v2.messages.ui.ConversationMessages -import com.android.messaging.ui.conversation.v2.metadata.ui.ConversationTopAppBar -import com.android.messaging.ui.conversation.v2.screen.model.ConversationMessageDeleteConfirmationUiState -import com.android.messaging.ui.conversation.v2.screen.model.ConversationScreenScaffoldUiState +import com.android.messaging.ui.conversation.CONVERSATION_LOADING_INDICATOR_TEST_TAG +import com.android.messaging.ui.conversation.composer.ui.ConversationComposerSection +import com.android.messaging.ui.conversation.composer.ui.ConversationSimSelectorSheet +import com.android.messaging.ui.conversation.entry.model.ConversationEntryStartupAttachment +import com.android.messaging.ui.conversation.mediapicker.rememberConversationMediaPickerPermissionState +import com.android.messaging.ui.conversation.mediapicker.rememberConversationMediaPickerState +import com.android.messaging.ui.conversation.messages.model.message.ConversationMessageUiModel +import com.android.messaging.ui.conversation.messages.model.message.ConversationMessagesUiState +import com.android.messaging.ui.conversation.messages.ui.ConversationMessages +import com.android.messaging.ui.conversation.metadata.ui.ConversationTopAppBar +import com.android.messaging.ui.conversation.screen.model.ConversationMessageDeleteConfirmationUiState +import com.android.messaging.ui.conversation.screen.model.ConversationScreenScaffoldUiState import kotlinx.collections.immutable.ImmutableList private const val SMOOTH_SCROLL_JUMP_THRESHOLD = 15 diff --git a/src/com/android/messaging/ui/conversation/v2/screen/ConversationScreenEffects.kt b/src/com/android/messaging/ui/conversation/screen/ConversationScreenEffects.kt similarity index 98% rename from src/com/android/messaging/ui/conversation/v2/screen/ConversationScreenEffects.kt rename to src/com/android/messaging/ui/conversation/screen/ConversationScreenEffects.kt index 0caeb43b..421d5863 100644 --- a/src/com/android/messaging/ui/conversation/v2/screen/ConversationScreenEffects.kt +++ b/src/com/android/messaging/ui/conversation/screen/ConversationScreenEffects.kt @@ -1,4 +1,4 @@ -package com.android.messaging.ui.conversation.v2.screen +package com.android.messaging.ui.conversation.screen import android.content.ActivityNotFoundException import android.content.ContentResolver @@ -22,7 +22,7 @@ import androidx.core.net.toUri import com.android.messaging.R import com.android.messaging.ui.UIIntents import com.android.messaging.ui.conversation.MessageDetailsDialog -import com.android.messaging.ui.conversation.v2.screen.model.ConversationScreenEffect +import com.android.messaging.ui.conversation.screen.model.ConversationScreenEffect import com.android.messaging.util.ContentType import com.android.messaging.util.LogUtil import com.android.messaging.util.UiUtils diff --git a/src/com/android/messaging/ui/conversation/v2/screen/ConversationScreenRoute.kt b/src/com/android/messaging/ui/conversation/screen/ConversationScreenRoute.kt similarity index 92% rename from src/com/android/messaging/ui/conversation/v2/screen/ConversationScreenRoute.kt rename to src/com/android/messaging/ui/conversation/screen/ConversationScreenRoute.kt index b1bdae2c..0a4e57f8 100644 --- a/src/com/android/messaging/ui/conversation/v2/screen/ConversationScreenRoute.kt +++ b/src/com/android/messaging/ui/conversation/screen/ConversationScreenRoute.kt @@ -1,4 +1,4 @@ -package com.android.messaging.ui.conversation.v2.screen +package com.android.messaging.ui.conversation.screen import android.Manifest import androidx.activity.compose.BackHandler @@ -23,14 +23,14 @@ import androidx.compose.ui.layout.onGloballyPositioned import androidx.lifecycle.Lifecycle import androidx.lifecycle.compose.LifecycleEventEffect import com.android.messaging.data.conversation.model.draft.ConversationDraft -import com.android.messaging.ui.conversation.v2.audio.model.ConversationAudioRecordingPhase -import com.android.messaging.ui.conversation.v2.entry.model.ConversationEntryStartupAttachment -import com.android.messaging.ui.conversation.v2.mediapicker.ConversationMediaPickerOverlay -import com.android.messaging.ui.conversation.v2.mediapicker.ConversationMediaPickerState -import com.android.messaging.ui.conversation.v2.mediapicker.RefreshConversationMediaPickerPermissionsEffect -import com.android.messaging.ui.conversation.v2.mediapicker.model.ConversationMediaPickerPermissionState -import com.android.messaging.ui.conversation.v2.screen.model.ConversationMediaPickerOverlayUiState -import com.android.messaging.ui.conversation.v2.screen.model.ConversationScreenScaffoldUiState +import com.android.messaging.ui.conversation.audio.model.ConversationAudioRecordingPhase +import com.android.messaging.ui.conversation.entry.model.ConversationEntryStartupAttachment +import com.android.messaging.ui.conversation.mediapicker.ConversationMediaPickerOverlay +import com.android.messaging.ui.conversation.mediapicker.ConversationMediaPickerState +import com.android.messaging.ui.conversation.mediapicker.RefreshConversationMediaPickerPermissionsEffect +import com.android.messaging.ui.conversation.mediapicker.model.ConversationMediaPickerPermissionState +import com.android.messaging.ui.conversation.screen.model.ConversationMediaPickerOverlayUiState +import com.android.messaging.ui.conversation.screen.model.ConversationScreenScaffoldUiState @Composable internal fun rememberOpenContactPickerCallback( diff --git a/src/com/android/messaging/ui/conversation/v2/screen/ConversationSelectionTopAppBar.kt b/src/com/android/messaging/ui/conversation/screen/ConversationSelectionTopAppBar.kt similarity index 97% rename from src/com/android/messaging/ui/conversation/v2/screen/ConversationSelectionTopAppBar.kt rename to src/com/android/messaging/ui/conversation/screen/ConversationSelectionTopAppBar.kt index 049be3e4..1bd0f6a9 100644 --- a/src/com/android/messaging/ui/conversation/v2/screen/ConversationSelectionTopAppBar.kt +++ b/src/com/android/messaging/ui/conversation/screen/ConversationSelectionTopAppBar.kt @@ -1,4 +1,4 @@ -package com.android.messaging.ui.conversation.v2.screen +package com.android.messaging.ui.conversation.screen import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.Forward @@ -30,8 +30,8 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import com.android.messaging.R -import com.android.messaging.ui.conversation.v2.screen.model.ConversationMessageSelectionAction -import com.android.messaging.ui.conversation.v2.screen.model.ConversationMessageSelectionUiState +import com.android.messaging.ui.conversation.screen.model.ConversationMessageSelectionAction +import com.android.messaging.ui.conversation.screen.model.ConversationMessageSelectionUiState import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableSet import kotlinx.collections.immutable.persistentListOf diff --git a/src/com/android/messaging/ui/conversation/v2/screen/ConversationViewModel.kt b/src/com/android/messaging/ui/conversation/screen/ConversationViewModel.kt similarity index 93% rename from src/com/android/messaging/ui/conversation/v2/screen/ConversationViewModel.kt rename to src/com/android/messaging/ui/conversation/screen/ConversationViewModel.kt index 0b0841b9..a89c0dd8 100644 --- a/src/com/android/messaging/ui/conversation/v2/screen/ConversationViewModel.kt +++ b/src/com/android/messaging/ui/conversation/screen/ConversationViewModel.kt @@ -1,4 +1,4 @@ -package com.android.messaging.ui.conversation.v2.screen +package com.android.messaging.ui.conversation.screen import android.app.Activity import androidx.lifecycle.SavedStateHandle @@ -14,25 +14,25 @@ import com.android.messaging.domain.conversation.usecase.action.CreateDefaultSms import com.android.messaging.domain.conversation.usecase.participant.CanAddMoreConversationParticipants import com.android.messaging.domain.conversation.usecase.telephony.IsDeviceVoiceCapable import com.android.messaging.domain.conversation.usecase.telephony.IsEmergencyPhoneNumber -import com.android.messaging.ui.conversation.v2.audio.delegate.ConversationAudioRecordingDelegate -import com.android.messaging.ui.conversation.v2.composer.delegate.ConversationComposerAttachmentsDelegate -import com.android.messaging.ui.conversation.v2.composer.delegate.ConversationDraftDelegate -import com.android.messaging.ui.conversation.v2.composer.mapper.ConversationComposerUiStateMapper -import com.android.messaging.ui.conversation.v2.composer.model.ComposerAttachmentUiModel -import com.android.messaging.ui.conversation.v2.composer.model.ConversationComposerUiState -import com.android.messaging.ui.conversation.v2.entry.model.ConversationEntryStartupAttachment -import com.android.messaging.ui.conversation.v2.focus.delegate.ConversationFocusDelegate -import com.android.messaging.ui.conversation.v2.mediapicker.delegate.ConversationMediaPickerDelegate -import com.android.messaging.ui.conversation.v2.messages.delegate.ConversationMessageSelectionDelegate -import com.android.messaging.ui.conversation.v2.messages.delegate.ConversationMessagesDelegate -import com.android.messaging.ui.conversation.v2.messages.model.message.ConversationMessagesUiState -import com.android.messaging.ui.conversation.v2.metadata.delegate.ConversationMetadataDelegate -import com.android.messaging.ui.conversation.v2.metadata.model.ConversationMetadataUiState -import com.android.messaging.ui.conversation.v2.screen.model.ConversationMediaPickerOverlayUiState -import com.android.messaging.ui.conversation.v2.screen.model.ConversationMessageSelectionAction -import com.android.messaging.ui.conversation.v2.screen.model.ConversationMessageSelectionUiState -import com.android.messaging.ui.conversation.v2.screen.model.ConversationScreenEffect -import com.android.messaging.ui.conversation.v2.screen.model.ConversationScreenScaffoldUiState +import com.android.messaging.ui.conversation.audio.delegate.ConversationAudioRecordingDelegate +import com.android.messaging.ui.conversation.composer.delegate.ConversationComposerAttachmentsDelegate +import com.android.messaging.ui.conversation.composer.delegate.ConversationDraftDelegate +import com.android.messaging.ui.conversation.composer.mapper.ConversationComposerUiStateMapper +import com.android.messaging.ui.conversation.composer.model.ComposerAttachmentUiModel +import com.android.messaging.ui.conversation.composer.model.ConversationComposerUiState +import com.android.messaging.ui.conversation.entry.model.ConversationEntryStartupAttachment +import com.android.messaging.ui.conversation.focus.delegate.ConversationFocusDelegate +import com.android.messaging.ui.conversation.mediapicker.delegate.ConversationMediaPickerDelegate +import com.android.messaging.ui.conversation.messages.delegate.ConversationMessageSelectionDelegate +import com.android.messaging.ui.conversation.messages.delegate.ConversationMessagesDelegate +import com.android.messaging.ui.conversation.messages.model.message.ConversationMessagesUiState +import com.android.messaging.ui.conversation.metadata.delegate.ConversationMetadataDelegate +import com.android.messaging.ui.conversation.metadata.model.ConversationMetadataUiState +import com.android.messaging.ui.conversation.screen.model.ConversationMediaPickerOverlayUiState +import com.android.messaging.ui.conversation.screen.model.ConversationMessageSelectionAction +import com.android.messaging.ui.conversation.screen.model.ConversationMessageSelectionUiState +import com.android.messaging.ui.conversation.screen.model.ConversationScreenEffect +import com.android.messaging.ui.conversation.screen.model.ConversationScreenScaffoldUiState import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject import kotlinx.collections.immutable.persistentListOf diff --git a/src/com/android/messaging/ui/conversation/v2/screen/PendingAudioRecordingStartMode.kt b/src/com/android/messaging/ui/conversation/screen/PendingAudioRecordingStartMode.kt similarity index 62% rename from src/com/android/messaging/ui/conversation/v2/screen/PendingAudioRecordingStartMode.kt rename to src/com/android/messaging/ui/conversation/screen/PendingAudioRecordingStartMode.kt index be70bd8d..cfc3d942 100644 --- a/src/com/android/messaging/ui/conversation/v2/screen/PendingAudioRecordingStartMode.kt +++ b/src/com/android/messaging/ui/conversation/screen/PendingAudioRecordingStartMode.kt @@ -1,4 +1,4 @@ -package com.android.messaging.ui.conversation.v2.screen +package com.android.messaging.ui.conversation.screen internal enum class PendingAudioRecordingStartMode { None, diff --git a/src/com/android/messaging/ui/conversation/v2/screen/model/ConversationMediaPickerOverlayUiState.kt b/src/com/android/messaging/ui/conversation/screen/model/ConversationMediaPickerOverlayUiState.kt similarity index 80% rename from src/com/android/messaging/ui/conversation/v2/screen/model/ConversationMediaPickerOverlayUiState.kt rename to src/com/android/messaging/ui/conversation/screen/model/ConversationMediaPickerOverlayUiState.kt index d6c570be..1a37037d 100644 --- a/src/com/android/messaging/ui/conversation/v2/screen/model/ConversationMediaPickerOverlayUiState.kt +++ b/src/com/android/messaging/ui/conversation/screen/model/ConversationMediaPickerOverlayUiState.kt @@ -1,7 +1,7 @@ -package com.android.messaging.ui.conversation.v2.screen.model +package com.android.messaging.ui.conversation.screen.model import androidx.compose.runtime.Immutable -import com.android.messaging.ui.conversation.v2.composer.model.ComposerAttachmentUiModel +import com.android.messaging.ui.conversation.composer.model.ComposerAttachmentUiModel import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableMap import kotlinx.collections.immutable.persistentListOf diff --git a/src/com/android/messaging/ui/conversation/v2/screen/model/ConversationMessageSelectionUiState.kt b/src/com/android/messaging/ui/conversation/screen/model/ConversationMessageSelectionUiState.kt similarity index 94% rename from src/com/android/messaging/ui/conversation/v2/screen/model/ConversationMessageSelectionUiState.kt rename to src/com/android/messaging/ui/conversation/screen/model/ConversationMessageSelectionUiState.kt index 157d789d..0a5e71a9 100644 --- a/src/com/android/messaging/ui/conversation/v2/screen/model/ConversationMessageSelectionUiState.kt +++ b/src/com/android/messaging/ui/conversation/screen/model/ConversationMessageSelectionUiState.kt @@ -1,4 +1,4 @@ -package com.android.messaging.ui.conversation.v2.screen.model +package com.android.messaging.ui.conversation.screen.model import androidx.compose.runtime.Immutable import kotlinx.collections.immutable.ImmutableSet diff --git a/src/com/android/messaging/ui/conversation/v2/screen/model/ConversationScreenEffect.kt b/src/com/android/messaging/ui/conversation/screen/model/ConversationScreenEffect.kt similarity index 96% rename from src/com/android/messaging/ui/conversation/v2/screen/model/ConversationScreenEffect.kt rename to src/com/android/messaging/ui/conversation/screen/model/ConversationScreenEffect.kt index 6202d507..677fc61c 100644 --- a/src/com/android/messaging/ui/conversation/v2/screen/model/ConversationScreenEffect.kt +++ b/src/com/android/messaging/ui/conversation/screen/model/ConversationScreenEffect.kt @@ -1,4 +1,4 @@ -package com.android.messaging.ui.conversation.v2.screen.model +package com.android.messaging.ui.conversation.screen.model import android.content.Intent import com.android.messaging.datamodel.data.ConversationMessageData diff --git a/src/com/android/messaging/ui/conversation/v2/screen/model/ConversationScreenScaffoldUiState.kt b/src/com/android/messaging/ui/conversation/screen/model/ConversationScreenScaffoldUiState.kt similarity index 68% rename from src/com/android/messaging/ui/conversation/v2/screen/model/ConversationScreenScaffoldUiState.kt rename to src/com/android/messaging/ui/conversation/screen/model/ConversationScreenScaffoldUiState.kt index 1abdfbd6..298922f9 100644 --- a/src/com/android/messaging/ui/conversation/v2/screen/model/ConversationScreenScaffoldUiState.kt +++ b/src/com/android/messaging/ui/conversation/screen/model/ConversationScreenScaffoldUiState.kt @@ -1,9 +1,9 @@ -package com.android.messaging.ui.conversation.v2.screen.model +package com.android.messaging.ui.conversation.screen.model import androidx.compose.runtime.Immutable -import com.android.messaging.ui.conversation.v2.composer.model.ConversationComposerUiState -import com.android.messaging.ui.conversation.v2.messages.model.message.ConversationMessagesUiState -import com.android.messaging.ui.conversation.v2.metadata.model.ConversationMetadataUiState +import com.android.messaging.ui.conversation.composer.model.ConversationComposerUiState +import com.android.messaging.ui.conversation.messages.model.message.ConversationMessagesUiState +import com.android.messaging.ui.conversation.metadata.model.ConversationMetadataUiState @Immutable internal data class ConversationScreenScaffoldUiState( diff --git a/src/com/android/messaging/ui/mediapicker/MediaPickerPanel.java b/src/com/android/messaging/ui/mediapicker/MediaPickerPanel.java index 4dd22a03..4460a512 100644 --- a/src/com/android/messaging/ui/mediapicker/MediaPickerPanel.java +++ b/src/com/android/messaging/ui/mediapicker/MediaPickerPanel.java @@ -203,12 +203,7 @@ private int getDesiredHeight() { if (isAttachedToWindow()) { // When we're attached to the window, we can get an accurate height, not necessary // on older API level devices because they don't include the action bar height - View composeContainer = - getRootView().findViewById(R.id.conversation_and_compose_container); - if (composeContainer != null) { - // protect against composeContainer having been unloaded already - fullHeight -= UiUtils.getMeasuredBoundsOnScreen(composeContainer).top; - } + fullHeight -= UiUtils.getMeasuredBoundsOnScreen(getRootView()).top; } if (mMediaPicker.getChooserShowsActionBarInFullScreen()) { return fullHeight - mActionBarHeight; @@ -558,4 +553,3 @@ private void resetState() { } } } - diff --git a/src/com/android/messaging/ui/photoviewer/BuglePhotoViewController.java b/src/com/android/messaging/ui/photoviewer/BuglePhotoViewController.java index c5587dca..6f8f73d4 100644 --- a/src/com/android/messaging/ui/photoviewer/BuglePhotoViewController.java +++ b/src/com/android/messaging/ui/photoviewer/BuglePhotoViewController.java @@ -37,7 +37,7 @@ import com.android.messaging.R; import com.android.messaging.datamodel.ConversationImagePartsView.PhotoViewQuery; import com.android.messaging.datamodel.MediaScratchFileProvider; -import com.android.messaging.ui.conversation.ConversationFragment; +import com.android.messaging.ui.AttachmentSaveTask; import com.android.messaging.util.Dates; import com.android.messaging.util.LogUtil; @@ -148,7 +148,7 @@ public boolean onOptionsItemSelected(final MenuItem item) { return true; } final String photoUri = adapter.getPhotoUri(cursor); - new ConversationFragment.SaveAttachmentTask(((Activity) getActivity()), + new AttachmentSaveTask(((Activity) getActivity()), Uri.parse(photoUri), adapter.getContentType(cursor)).executeOnThreadPool(); return true; } else if (itemId == R.id.action_share) { From 64f50e892ae3f7d872091847fc1227eb1fc19564 Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Fri, 1 May 2026 13:44:03 +0300 Subject: [PATCH 93/99] Fix ktlint error --- .../ui/attachment/ConversationInlineAudioAttachmentRow.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/src/com/android/messaging/ui/conversation/messages/ui/attachment/ConversationInlineAudioAttachmentRow.kt b/src/com/android/messaging/ui/conversation/messages/ui/attachment/ConversationInlineAudioAttachmentRow.kt index d0e815ca..e3666c5b 100644 --- a/src/com/android/messaging/ui/conversation/messages/ui/attachment/ConversationInlineAudioAttachmentRow.kt +++ b/src/com/android/messaging/ui/conversation/messages/ui/attachment/ConversationInlineAudioAttachmentRow.kt @@ -252,7 +252,6 @@ internal fun rememberConversationInlineAudioAttachmentColors( ) } - @Composable private fun getAudioAttachmentContainerColor( isIncoming: Boolean, From b8ca40cb3a4522f4e979b01d3c4dc4a1783d2077 Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Fri, 1 May 2026 14:04:00 +0300 Subject: [PATCH 94/99] Fix stale MMS detection after attachments removed --- .../delegate/ConversationDraftDelegate.kt | 25 +++++++++++++++---- .../delegate/ConversationDraftEditorState.kt | 23 +++++++++++------ 2 files changed, 35 insertions(+), 13 deletions(-) diff --git a/src/com/android/messaging/ui/conversation/composer/delegate/ConversationDraftDelegate.kt b/src/com/android/messaging/ui/conversation/composer/delegate/ConversationDraftDelegate.kt index da1a313a..ff0bb2d3 100644 --- a/src/com/android/messaging/ui/conversation/composer/delegate/ConversationDraftDelegate.kt +++ b/src/com/android/messaging/ui/conversation/composer/delegate/ConversationDraftDelegate.kt @@ -289,7 +289,7 @@ internal class ConversationDraftDelegateImpl @Inject constructor( } updateDraftEditorState { currentDraftEditorState -> - currentDraftEditorState.markPersistedIfUnchanged( + currentDraftEditorState.withPersistedSaveResult( saveRequest = saveRequest, ) } @@ -640,10 +640,10 @@ internal class ConversationDraftDelegateImpl @Inject constructor( draftEditorState.update { currentDraftEditorState -> val updatedDraftEditorState = transform(currentDraftEditorState) val visibleState = updatedDraftEditorState.visibleState - val visibleSendProtocol = when { - visibleState.draft.hasContent -> _state.value.sendProtocol - else -> ConversationDraftSendProtocol.SMS - } + val visibleSendProtocol = resolveVisibleSendProtocol( + previousState = _state.value, + visibleState = visibleState, + ) _state.value = visibleState.copy( sendProtocol = visibleSendProtocol, @@ -653,6 +653,21 @@ internal class ConversationDraftDelegateImpl @Inject constructor( } } + private fun resolveVisibleSendProtocol( + previousState: ConversationDraftState, + visibleState: ConversationDraftState, + ): ConversationDraftSendProtocol { + val visibleDraft = visibleState.draft + val previousDraft = previousState.draft + + return when { + !visibleDraft.hasContent -> ConversationDraftSendProtocol.SMS + visibleDraft.isMms -> ConversationDraftSendProtocol.MMS + previousDraft.isMms -> ConversationDraftSendProtocol.SMS + else -> previousState.sendProtocol + } + } + private fun applyPendingDraftSeedIfPossible() { val pendingDraftSeed = pendingDraftSeed ?: return diff --git a/src/com/android/messaging/ui/conversation/composer/delegate/ConversationDraftEditorState.kt b/src/com/android/messaging/ui/conversation/composer/delegate/ConversationDraftEditorState.kt index e875c804..2e45de71 100644 --- a/src/com/android/messaging/ui/conversation/composer/delegate/ConversationDraftEditorState.kt +++ b/src/com/android/messaging/ui/conversation/composer/delegate/ConversationDraftEditorState.kt @@ -231,18 +231,25 @@ internal data class DraftEditorState( effectiveDraft.hasContent } - fun markPersistedIfUnchanged(saveRequest: DraftSaveRequest): DraftEditorState { + fun withPersistedSaveResult(saveRequest: DraftSaveRequest): DraftEditorState { return when { conversationId != saveRequest.conversationId -> this - effectiveDraft != saveRequest.draft -> this + effectiveDraft == saveRequest.draft -> { + copy( + persistedDraft = saveRequest.draft, + localEdits = ConversationDraftEdits(), + isLoaded = true, + pendingSentDraft = null, + ) + } - else -> copy( - persistedDraft = saveRequest.draft, - localEdits = ConversationDraftEdits(), - isLoaded = true, - pendingSentDraft = null, - ) + else -> { + rebaseVisibleDraftOnPersistedDraft( + persistedDraft = saveRequest.draft, + shouldKeepPendingSentDraft = false, + ) + } } } From a29951c00348aadaef291d0b740b78c9074d3d10 Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Wed, 6 May 2026 18:54:53 +0300 Subject: [PATCH 95/99] Fix blank self participant id handling for drafts --- .../store/ConversationDraftStoreTest.kt | 53 +++++++++++++++++++ .../ConversationDraftsRepository.kt | 4 ++ .../store/ConversationDraftStore.kt | 2 +- 3 files changed, 58 insertions(+), 1 deletion(-) create mode 100644 app/src/test/java/com/android/messaging/data/conversation/store/ConversationDraftStoreTest.kt diff --git a/app/src/test/java/com/android/messaging/data/conversation/store/ConversationDraftStoreTest.kt b/app/src/test/java/com/android/messaging/data/conversation/store/ConversationDraftStoreTest.kt new file mode 100644 index 00000000..7f5f1cfa --- /dev/null +++ b/app/src/test/java/com/android/messaging/data/conversation/store/ConversationDraftStoreTest.kt @@ -0,0 +1,53 @@ +package com.android.messaging.data.conversation.store + +import com.android.messaging.datamodel.DataModel +import com.android.messaging.datamodel.DatabaseWrapper +import com.android.messaging.datamodel.data.ConversationListItemData +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkAll +import org.junit.After +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test + +internal class ConversationDraftStoreTest { + + private val databaseWrapper = mockk() + private val dataModel = mockk() + + private val store = ConversationDraftStoreImpl() + + @Before + fun setUp() { + mockkStatic(DataModel::class) + mockkStatic(ConversationListItemData::class) + + every { DataModel.get() } returns dataModel + every { dataModel.database } returns databaseWrapper + } + + @After + fun tearDown() { + unmockkAll() + } + + @Test + fun getSelfParticipantIdReturnsNullWhenConversationSelfIdIsMissing() { + val conversation = ConversationListItemData() + every { + ConversationListItemData.getExistingConversation(databaseWrapper, CONVERSATION_ID) + } returns conversation + + val selfParticipantId = store.getSelfParticipantId( + conversationId = CONVERSATION_ID, + ) + + assertNull(selfParticipantId) + } + + private companion object { + private const val CONVERSATION_ID = "conversation-id" + } +} diff --git a/src/com/android/messaging/data/conversation/repository/ConversationDraftsRepository.kt b/src/com/android/messaging/data/conversation/repository/ConversationDraftsRepository.kt index 41264f7d..f7bfc687 100644 --- a/src/com/android/messaging/data/conversation/repository/ConversationDraftsRepository.kt +++ b/src/com/android/messaging/data/conversation/repository/ConversationDraftsRepository.kt @@ -238,6 +238,10 @@ internal class ConversationDraftsRepositoryImpl @Inject constructor( message: MessageData, selfParticipantId: String, ): MessageData { + if (selfParticipantId.isBlank()) { + return message + } + if (message.selfId == null) { message.bindSelfId(selfParticipantId) } diff --git a/src/com/android/messaging/data/conversation/store/ConversationDraftStore.kt b/src/com/android/messaging/data/conversation/store/ConversationDraftStore.kt index 18729f75..5786a8c6 100644 --- a/src/com/android/messaging/data/conversation/store/ConversationDraftStore.kt +++ b/src/com/android/messaging/data/conversation/store/ConversationDraftStore.kt @@ -28,7 +28,7 @@ internal class ConversationDraftStoreImpl @Inject constructor() : ConversationDr conversationId, ) ?: return null - return conversation.selfId.orEmpty() + return conversation.selfId?.takeIf { it.isNotBlank() } } override fun readDraftMessage( From 7375ae5aa769456ba0f11f54553b3edafbcd079e Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Wed, 6 May 2026 21:43:49 +0300 Subject: [PATCH 96/99] Fix long-press on messages containing links --- .../ConversationMessageLinkLongClickTest.kt | 173 +++++++++++++ .../android/messaging/debug/TestDataSeeder.kt | 3 + .../ui/message/ConversationMessageBubble.kt | 2 + .../ui/text/ConversationMessageText.kt | 232 ++++++++++++++++-- 4 files changed, 392 insertions(+), 18 deletions(-) create mode 100644 app/src/androidTest/java/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessageLinkLongClickTest.kt diff --git a/app/src/androidTest/java/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessageLinkLongClickTest.kt b/app/src/androidTest/java/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessageLinkLongClickTest.kt new file mode 100644 index 00000000..a4479063 --- /dev/null +++ b/app/src/androidTest/java/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessageLinkLongClickTest.kt @@ -0,0 +1,173 @@ +package com.android.messaging.ui.conversation.messages.ui.message + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.longClick +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTouchInput +import com.android.messaging.ui.conversation.messages.model.message.ConversationMessagePartUiModel +import com.android.messaging.ui.conversation.messages.model.message.ConversationMessageUiModel +import com.android.messaging.ui.core.AppTheme +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test + +private const val MESSAGE_ID = "message-id" +private const val CONVERSATION_ID = "conversation-id" +private const val LINK_ONLY_TEXT = "https://example.com" +private const val PLAIN_TEXT = "plain outgoing message" +private const val TIMESTAMP = 1_700_000_000_000L + +internal class ConversationMessageLinkLongClickTest { + @get:Rule + val composeRule = createComposeRule() + + @Test + fun longClickOutgoingLinkOnlyMessageSelectsMessage() { + var externalUriClickCount = 0 + var messageLongClickCount = 0 + + composeRule.setContent { + AppTheme { + ConversationMessage( + message = outgoingMessage(text = LINK_ONLY_TEXT), + onExternalUriClick = { + externalUriClickCount += 1 + }, + onMessageLongClick = { + messageLongClickCount += 1 + }, + ) + } + } + + composeRule.waitForIdle() + + composeRule + .onNodeWithText(text = LINK_ONLY_TEXT, useUnmergedTree = true) + .performClick() + + composeRule.runOnIdle { + assertEquals(1, externalUriClickCount) + } + + composeRule + .onNodeWithText(text = LINK_ONLY_TEXT, useUnmergedTree = true) + .performTouchInput { + longClick(position = center) + } + + composeRule.runOnIdle { + assertEquals(1, externalUriClickCount) + assertEquals(1, messageLongClickCount) + } + } + + @Test + fun longClickOutgoingLinkOnlyMessageStaysSelectedAfterRelease() { + var externalUriClickCount = 0 + var messageClickCount = 0 + var messageLongClickCount = 0 + var isSelected by mutableStateOf(false) + var isSelectionMode by mutableStateOf(false) + + composeRule.setContent { + AppTheme { + ConversationMessage( + message = outgoingMessage(text = LINK_ONLY_TEXT), + isSelected = isSelected, + isSelectionMode = isSelectionMode, + onExternalUriClick = { + externalUriClickCount += 1 + }, + onMessageClick = { + messageClickCount += 1 + isSelected = !isSelected + }, + onMessageLongClick = { + messageLongClickCount += 1 + isSelectionMode = true + isSelected = true + }, + ) + } + } + + composeRule.waitForIdle() + + composeRule + .onNodeWithText(text = LINK_ONLY_TEXT, useUnmergedTree = true) + .performTouchInput { + longClick(position = center) + } + + composeRule.runOnIdle { + assertEquals(0, externalUriClickCount) + assertEquals(0, messageClickCount) + assertEquals(1, messageLongClickCount) + assertEquals(true, isSelected) + assertEquals(true, isSelectionMode) + } + } + + @Test + fun longClickOutgoingPlainTextMessageSelectsMessageOnce() { + var messageLongClickCount = 0 + + composeRule.setContent { + AppTheme { + ConversationMessage( + message = outgoingMessage(text = PLAIN_TEXT), + onMessageLongClick = { + messageLongClickCount += 1 + }, + ) + } + } + + composeRule.waitForIdle() + + composeRule + .onNodeWithText(text = PLAIN_TEXT, useUnmergedTree = true) + .performTouchInput { + longClick(position = center) + } + + composeRule.runOnIdle { + assertEquals(1, messageLongClickCount) + } + } +} + +private fun outgoingMessage(text: String): ConversationMessageUiModel { + return ConversationMessageUiModel( + messageId = MESSAGE_ID, + conversationId = CONVERSATION_ID, + text = text, + parts = listOf( + ConversationMessagePartUiModel.Text( + text = text, + ), + ), + sentTimestamp = TIMESTAMP, + receivedTimestamp = TIMESTAMP, + displayTimestamp = TIMESTAMP, + status = ConversationMessageUiModel.Status.Outgoing.Complete, + isIncoming = false, + senderDisplayName = null, + senderAvatarUri = null, + senderContactLookupKey = null, + canClusterWithPrevious = false, + canClusterWithNext = false, + canCopyMessageToClipboard = true, + canDownloadMessage = false, + canForwardMessage = true, + canResendMessage = false, + canSaveAttachments = false, + mmsSubject = null, + protocol = ConversationMessageUiModel.Protocol.SMS, + ) +} diff --git a/src/com/android/messaging/debug/TestDataSeeder.kt b/src/com/android/messaging/debug/TestDataSeeder.kt index ce228fa0..918949d2 100644 --- a/src/com/android/messaging/debug/TestDataSeeder.kt +++ b/src/com/android/messaging/debug/TestDataSeeder.kt @@ -38,6 +38,7 @@ import kotlin.math.sin private const val TAG = "TestDataSeeder" private const val TEST_PHONE_PREFIX = "+15550" private const val TEST_YOUTUBE_VIDEO_URL = "https://www.youtube.com/watch?v=dQw4w9WgXcQ" +private const val TEST_LINK_MESSAGE_URL = "https://grapheneos.org" private const val MEDIA_SCRATCH_FILE_EXTENSION_QUERY_PARAMETER = "ext" private const val SEED_IMAGE_1_FILE_ID = "800001" private const val SEED_IMAGE_2_FILE_ID = "800002" @@ -1052,6 +1053,7 @@ private fun seedScenarioF(db: DatabaseWrapper, selfId: String, henryId: String, "It's important", "Please reply when you get a chance", "I'll be online for the next hour", + TEST_LINK_MESSAGE_URL, ) var latestMsgId = 0L @@ -1118,6 +1120,7 @@ private fun seedScenarioG( Msg("text", text = "I took that one on the way home", isIncoming = false), Msg("text", text = "You have such a good eye for photos!", isIncoming = true), Msg("text", text = "Thanks! We should go together sometime", isIncoming = false), + Msg("text", text = TEST_LINK_MESSAGE_URL, isIncoming = false), Msg("text", text = "Definitely, let me know when you're free", isIncoming = true), Msg("image", imageUri = img2, isIncoming = true), Msg("text", text = "And one more from yesterday", isIncoming = true), 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 73373451..7146b9dd 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 @@ -345,6 +345,7 @@ private fun ConversationMessageAttachmentBubbleContent( text = bodyText, style = MaterialTheme.typography.bodyLarge, onExternalUriClick = onExternalUriClick, + onMessageLongClick = onMessageLongClick, ) } } @@ -392,6 +393,7 @@ private fun ConversationMessageBody( text = bodyText, style = MaterialTheme.typography.bodyLarge, onExternalUriClick = onExternalUriClick, + onMessageLongClick = onMessageLongClick, ) } } diff --git a/src/com/android/messaging/ui/conversation/messages/ui/text/ConversationMessageText.kt b/src/com/android/messaging/ui/conversation/messages/ui/text/ConversationMessageText.kt index b6e38240..377ba5bb 100644 --- a/src/com/android/messaging/ui/conversation/messages/ui/text/ConversationMessageText.kt +++ b/src/com/android/messaging/ui/conversation/messages/ui/text/ConversationMessageText.kt @@ -1,16 +1,34 @@ package com.android.messaging.ui.conversation.messages.ui.text +import android.os.SystemClock +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.produceState import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.input.pointer.AwaitPointerEventScope +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.PointerEventTimeoutCancellationException +import androidx.compose.ui.input.pointer.PointerInputScope +import androidx.compose.ui.input.pointer.changedToUp +import androidx.compose.ui.input.pointer.isOutOfBounds +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.LinkAnnotation import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextLayoutResult import androidx.compose.ui.text.TextLinkStyles import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.buildAnnotatedString @@ -20,34 +38,29 @@ import com.android.messaging.ui.conversation.messages.model.text.ConversationTex import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +private const val LINK_CLICK_SUPPRESSION_AFTER_LONG_PRESS_MILLIS = 500L + @Composable internal fun ConversationMessageText( + modifier: Modifier = Modifier, text: String, style: TextStyle, onExternalUriClick: (String) -> Unit, - modifier: Modifier = Modifier, + onMessageLongClick: () -> Unit, ) { - val context = LocalContext.current - val linkColor = MaterialTheme.colorScheme.primary - val linkStyle = remember(linkColor) { - TextLinkStyles( - style = SpanStyle( - color = linkColor, - textDecoration = TextDecoration.Underline, - ), - ) - } - + val applicationContext = LocalContext.current.applicationContext + val currentOnExternalUriClick by rememberUpdatedState(newValue = onExternalUriClick) + val linkStyle = rememberConversationTextLinkStyle() + val suppressLinkClickUntilUptimeMillis = remember { mutableLongStateOf(0L) } val textWithLinks by produceState( initialValue = AnnotatedString(text = text), + applicationContext, text, linkStyle, - onExternalUriClick, - context.applicationContext, ) { val links = withContext(Dispatchers.IO) { extractConversationTextLinks( - context = context.applicationContext, + context = applicationContext, text = text, ) } @@ -56,17 +69,200 @@ internal fun ConversationMessageText( text = text, links = links, linkStyle = linkStyle, - onExternalUriClick = onExternalUriClick, + onExternalUriClick = { uri -> + if (shouldSuppressConversationTextLinkClick( + suppressUntilUptimeMillis = suppressLinkClickUntilUptimeMillis.longValue, + ) + ) { + suppressLinkClickUntilUptimeMillis.longValue = 0L + return@buildConversationLinkedAnnotatedString + } + + currentOnExternalUriClick(uri) + }, ) } - Text( + ConversationMessageTextContent( + modifier = modifier, text = textWithLinks, style = style, - modifier = modifier, + onLinkLongPress = onMessageLongClick, + suppressNextLinkClick = { + suppressLinkClickUntilUptimeMillis.longValue = + conversationTextLinkClickSuppressionDeadlineUptimeMillis() + }, + ) +} + +@Composable +private fun ConversationMessageTextContent( + modifier: Modifier = Modifier, + text: AnnotatedString, + style: TextStyle, + onLinkLongPress: () -> Unit, + suppressNextLinkClick: () -> Unit, +) { + val hapticFeedback = LocalHapticFeedback.current + val currentOnLinkLongPress by rememberUpdatedState(newValue = onLinkLongPress) + val currentSuppressNextLinkClick by rememberUpdatedState(newValue = suppressNextLinkClick) + var textLayoutResult by remember { mutableStateOf(null) } + + val hasLinkAnnotations = text.hasLinkAnnotations( + start = 0, + end = text.length, + ) + + val textLongPressModifier = when { + hasLinkAnnotations -> { + Modifier.pointerInput(text, textLayoutResult) { + detectConversationTextLinkLongPresses( + text = text, + textLayoutResult = textLayoutResult, + onLongPress = { + hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) + currentOnLinkLongPress() + }, + suppressNextLinkClick = { + currentSuppressNextLinkClick() + }, + ) + } + } + + else -> Modifier + } + + Text( + text = text, + style = style, + modifier = modifier.then(other = textLongPressModifier), + onTextLayout = { result -> + textLayoutResult = result + }, ) } +@Composable +private fun rememberConversationTextLinkStyle(): TextLinkStyles { + val linkColor = MaterialTheme.colorScheme.primary + + return remember(linkColor) { + TextLinkStyles( + style = SpanStyle( + color = linkColor, + textDecoration = TextDecoration.Underline, + ), + ) + } +} + +private fun conversationTextLinkClickSuppressionDeadlineUptimeMillis(): Long { + return SystemClock.uptimeMillis() + LINK_CLICK_SUPPRESSION_AFTER_LONG_PRESS_MILLIS +} + +private fun shouldSuppressConversationTextLinkClick(suppressUntilUptimeMillis: Long): Boolean { + return SystemClock.uptimeMillis() <= suppressUntilUptimeMillis +} + +private suspend fun PointerInputScope.detectConversationTextLinkLongPresses( + text: AnnotatedString, + textLayoutResult: TextLayoutResult?, + onLongPress: () -> Unit, + suppressNextLinkClick: () -> Unit, +) { + awaitEachGesture { + val down = awaitFirstDown( + requireUnconsumed = false, + pass = PointerEventPass.Initial, + ) + + val hasConversationLinkAtPressPosition = textLayoutResult + ?.hasConversationLinkAtPosition(text = text, position = down.position) == true + + if (!hasConversationLinkAtPressPosition) { + return@awaitEachGesture + } + + val isLongPressConfirmed = awaitConversationTextLongPressConfirmation() + + if (isLongPressConfirmed) { + suppressNextLinkClick() + onLongPress() + consumeConversationTextGestureUntilUp() + suppressNextLinkClick() + } + } +} + +private fun TextLayoutResult.hasConversationLinkAtPosition( + text: AnnotatedString, + position: Offset, +): Boolean { + val offset = getOffsetForPosition(position = position) + val endOffset = (offset + 1).coerceAtMost(maximumValue = text.length) + + return when { + offset >= endOffset -> false + else -> { + text.hasLinkAnnotations( + start = offset, + end = endOffset, + ) + } + } +} + +private suspend fun AwaitPointerEventScope.consumeConversationTextGestureUntilUp() { + var isPointerActive = true + + while (isPointerActive) { + val event = awaitPointerEvent(pass = PointerEventPass.Initial) + + event.changes.forEach { change -> + change.consume() + } + + val allPointersUp = event.changes.all { change -> change.changedToUp() } + + if (allPointersUp) { + isPointerActive = false + } + } +} + +private suspend fun AwaitPointerEventScope.awaitConversationTextLongPressConfirmation(): Boolean { + try { + withTimeout(timeMillis = viewConfiguration.longPressTimeoutMillis) { + var isPointerActive = true + + while (isPointerActive) { + val event = awaitPointerEvent(pass = PointerEventPass.Initial) + val hasPointerLeftBounds = event.changes.any { change -> + change.isOutOfBounds( + size = size, + extendedTouchPadding = extendedTouchPadding, + ) + } + + val allPointersUp = event.changes.all { change -> change.changedToUp() } + + if (allPointersUp) { + isPointerActive = false + } + + if (hasPointerLeftBounds) { + isPointerActive = false + } + } + } + } catch (_: PointerEventTimeoutCancellationException) { + return true + } + + return false +} + private fun buildConversationLinkedAnnotatedString( text: String, links: List, From dc6e77fc4238a634486023c5a9d6294ea8f51d52 Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Wed, 6 May 2026 21:50:51 +0300 Subject: [PATCH 97/99] Fix link colors for selected messages --- .../ui/message/ConversationMessageBubble.kt | 23 +++++++++++++++---- .../ui/text/ConversationMessageText.kt | 11 ++++++++- 2 files changed, 28 insertions(+), 6 deletions(-) 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 7146b9dd..5943077c 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 @@ -14,6 +14,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -25,6 +26,7 @@ import com.android.messaging.ui.conversation.messages.model.message.Conversation import com.android.messaging.ui.conversation.messages.model.message.ConversationMessageUiModel import com.android.messaging.ui.conversation.messages.ui.attachment.ConversationMessageAttachments import com.android.messaging.ui.conversation.messages.ui.text.ConversationMessageText +import com.android.messaging.ui.conversation.messages.ui.text.LocalConversationMessageLinkColor private val MESSAGE_BUBBLE_MEDIA_SECTION_SPACING = 8.dp private val MESSAGE_BUBBLE_MEDIA_TEXT_PADDING = 12.dp @@ -186,19 +188,30 @@ private fun ConversationMessageBubbleSurface( layout: ConversationMessageLayout, bubbleContent: @Composable () -> Unit, ) { + val contentColor = messageBubbleContentColor( + message = message, + isSelected = isSelected, + ) + + val linkColor = when { + isSelected -> contentColor + else -> MaterialTheme.colorScheme.primary + } + Surface( color = messageBubbleColor( message = message, isSelected = isSelected, ), - contentColor = messageBubbleContentColor( - message = message, - isSelected = isSelected, - ), + contentColor = contentColor, shape = layout.bubbleShape, modifier = modifier, ) { - bubbleContent() + CompositionLocalProvider( + LocalConversationMessageLinkColor provides linkColor, + ) { + bubbleContent() + } } } diff --git a/src/com/android/messaging/ui/conversation/messages/ui/text/ConversationMessageText.kt b/src/com/android/messaging/ui/conversation/messages/ui/text/ConversationMessageText.kt index 377ba5bb..f53dabe8 100644 --- a/src/com/android/messaging/ui/conversation/messages/ui/text/ConversationMessageText.kt +++ b/src/com/android/messaging/ui/conversation/messages/ui/text/ConversationMessageText.kt @@ -6,6 +6,8 @@ import androidx.compose.foundation.gestures.awaitFirstDown import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.ProvidableCompositionLocal +import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableStateOf @@ -15,6 +17,7 @@ import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.input.pointer.AwaitPointerEventScope import androidx.compose.ui.input.pointer.PointerEventPass @@ -40,6 +43,11 @@ import kotlinx.coroutines.withContext private const val LINK_CLICK_SUPPRESSION_AFTER_LONG_PRESS_MILLIS = 500L +internal val LocalConversationMessageLinkColor: ProvidableCompositionLocal = + compositionLocalOf { + null + } + @Composable internal fun ConversationMessageText( modifier: Modifier = Modifier, @@ -145,7 +153,8 @@ private fun ConversationMessageTextContent( @Composable private fun rememberConversationTextLinkStyle(): TextLinkStyles { - val linkColor = MaterialTheme.colorScheme.primary + val linkColor = LocalConversationMessageLinkColor.current + ?: MaterialTheme.colorScheme.primary return remember(linkColor) { TextLinkStyles( From afe6aae0e81e2976bb33cb15861edde016db8297 Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Wed, 6 May 2026 22:35:09 +0300 Subject: [PATCH 98/99] Don't show phone number for known contacts in conversation --- .../ui/conversation/metadata/ui/ConversationTopAppBar.kt | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) 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 97ec8d93..6a562dba 100644 --- a/src/com/android/messaging/ui/conversation/metadata/ui/ConversationTopAppBar.kt +++ b/src/com/android/messaging/ui/conversation/metadata/ui/ConversationTopAppBar.kt @@ -568,9 +568,13 @@ private fun shouldShowOneOnOneSubtitle( ): Boolean { val displayDestination = metadata.otherParticipantDisplayDestination ?.takeIf { it.isNotBlank() } - ?: return false - return !displayDestination.equals(other = metadata.title, ignoreCase = false) + return when { + displayDestination == null -> false + !metadata.otherParticipantContactLookupKey.isNullOrBlank() -> false + displayDestination.equals(other = metadata.title, ignoreCase = false) -> false + else -> true + } } @Immutable From 8d25e33863739e8b19015de08dd35d39b9434c61 Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Wed, 6 May 2026 22:51:45 +0300 Subject: [PATCH 99/99] Don't show name/number in 1-1 conversations --- .../messages/ui/ConversationMessages.kt | 4 ++++ .../messages/ui/message/ConversationMessage.kt | 9 ++++++++- .../ui/conversation/screen/ConversationScreen.kt | 15 +++++++++++++++ 3 files changed, 27 insertions(+), 1 deletion(-) 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 9cc3e0bc..6189c302 100644 --- a/src/com/android/messaging/ui/conversation/messages/ui/ConversationMessages.kt +++ b/src/com/android/messaging/ui/conversation/messages/ui/ConversationMessages.kt @@ -59,6 +59,7 @@ internal fun ConversationMessages( messages: ImmutableList, listState: LazyListState, selectedMessageIds: ImmutableSet = persistentSetOf(), + showIncomingSenderLabels: Boolean = true, onAttachmentClick: (contentType: String, contentUri: String) -> Unit, onExternalUriClick: (String) -> Unit, onMessageClick: (String) -> Unit, @@ -101,6 +102,7 @@ internal fun ConversationMessages( ), isSelectionMode = selectedMessageIds.isNotEmpty(), isSelected = selectedMessageIds.contains(message.messageId), + showIncomingSenderLabels = showIncomingSenderLabels, onAttachmentClick = onAttachmentClick, onExternalUriClick = onExternalUriClick, onMessageClick = onMessageClick, @@ -151,6 +153,7 @@ private fun ConversationMessagesItem( messageAbove: ConversationMessageUiModel?, isSelectionMode: Boolean, isSelected: Boolean, + showIncomingSenderLabels: Boolean, onAttachmentClick: (contentType: String, contentUri: String) -> Unit, onExternalUriClick: (String) -> Unit, onMessageClick: (String) -> Unit, @@ -173,6 +176,7 @@ private fun ConversationMessagesItem( isSelected = isSelected, isSelectionMode = isSelectionMode, message = message, + showIncomingSenderLabel = showIncomingSenderLabels, onAttachmentClick = onAttachmentClick, onExternalUriClick = onExternalUriClick, onMessageClick = { 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 fcc2dcaf..d963f4d9 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 @@ -43,6 +43,7 @@ internal fun ConversationMessage( message: ConversationMessageUiModel, isSelected: Boolean = false, isSelectionMode: Boolean = false, + showIncomingSenderLabel: Boolean = true, onAttachmentClick: (contentType: String, contentUri: String) -> Unit = { _, _ -> }, onExternalUriClick: (String) -> Unit = {}, onMessageClick: () -> Unit = {}, @@ -57,7 +58,10 @@ internal fun ConversationMessage( (maxWidth * MESSAGE_BUBBLE_WIDTH_FRACTION) .coerceAtMost(MESSAGE_BUBBLE_MAX_WIDTH_DP.dp) } - val layout = rememberConversationMessageLayout(message = message) + val layout = rememberConversationMessageLayout( + message = message, + showIncomingSenderLabel = showIncomingSenderLabel, + ) Row( modifier = Modifier.fillMaxWidth(), @@ -97,6 +101,7 @@ internal enum class ConversationMessageBubbleLayoutMode { @Composable private fun rememberConversationMessageLayout( message: ConversationMessageUiModel, + showIncomingSenderLabel: Boolean, ): ConversationMessageLayout { val bubbleShape = remember( message.canClusterWithPrevious, @@ -109,11 +114,13 @@ private fun rememberConversationMessageLayout( val metadataText = rememberConversationMessageMetadataText(message = message) val showSender = remember( + showIncomingSenderLabel, message.isIncoming, message.senderDisplayName, message.canClusterWithPrevious, ) { message.isIncoming && + showIncomingSenderLabel && !message.senderDisplayName.isNullOrBlank() && !message.canClusterWithPrevious } diff --git a/src/com/android/messaging/ui/conversation/screen/ConversationScreen.kt b/src/com/android/messaging/ui/conversation/screen/ConversationScreen.kt index 1adf19b8..5c3ffb27 100644 --- a/src/com/android/messaging/ui/conversation/screen/ConversationScreen.kt +++ b/src/com/android/messaging/ui/conversation/screen/ConversationScreen.kt @@ -42,6 +42,7 @@ import com.android.messaging.ui.conversation.mediapicker.rememberConversationMed import com.android.messaging.ui.conversation.messages.model.message.ConversationMessageUiModel import com.android.messaging.ui.conversation.messages.model.message.ConversationMessagesUiState import com.android.messaging.ui.conversation.messages.ui.ConversationMessages +import com.android.messaging.ui.conversation.metadata.model.ConversationMetadataUiState import com.android.messaging.ui.conversation.metadata.ui.ConversationTopAppBar import com.android.messaging.ui.conversation.screen.model.ConversationMessageDeleteConfirmationUiState import com.android.messaging.ui.conversation.screen.model.ConversationScreenScaffoldUiState @@ -394,6 +395,10 @@ private fun ConversationScreenContent( conversationId = conversationId, ) + val showIncomingSenderLabels = shouldShowIncomingSenderLabels( + metadata = uiState.metadata, + ) + AutoScrollToLatestMessage( conversationId = conversationId, messages = messagesState.messages, @@ -414,6 +419,7 @@ private fun ConversationScreenContent( messages = messagesState.messages, listState = messagesListState, selectedMessageIds = uiState.selection.selectedMessageIds, + showIncomingSenderLabels = showIncomingSenderLabels, onAttachmentClick = onAttachmentClick, onExternalUriClick = onExternalUriClick, onMessageClick = onMessageClick, @@ -424,6 +430,15 @@ private fun ConversationScreenContent( } } +private fun shouldShowIncomingSenderLabels(metadata: ConversationMetadataUiState): Boolean { + return when (metadata) { + is ConversationMetadataUiState.Present -> metadata.participantCount > 1 + ConversationMetadataUiState.Loading, + ConversationMetadataUiState.Unavailable, + -> false + } +} + @Composable private fun ConversationDeleteMessagesDialog( deleteConfirmation: ConversationMessageDeleteConfirmationUiState,