From b1b988924645eaa77b2e076a86959cf9d5b0f448 Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Tue, 24 Mar 2026 20:51:23 +0200 Subject: [PATCH 001/136] 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 002/136] 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 003/136] 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 004/136] 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 005/136] 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 006/136] 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 007/136] 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 008/136] 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 009/136] 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 010/136] 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 011/136] 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 012/136] 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 013/136] 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 014/136] 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 015/136] 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 016/136] 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 017/136] 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 018/136] 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 019/136] 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 020/136] 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 021/136] 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 022/136] 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 023/136] 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 024/136] 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 025/136] 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 026/136] 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 027/136] 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 028/136] 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 029/136] 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 030/136] 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 031/136] 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 032/136] 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 033/136] 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 034/136] 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 035/136] 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 036/136] 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 037/136] 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 038/136] 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 039/136] 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 040/136] 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 041/136] 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 042/136] 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 043/136] 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 044/136] 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 045/136] 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 046/136] 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 047/136] 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 048/136] 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 049/136] 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 050/136] 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 051/136] 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 052/136] 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 053/136] 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 054/136] 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 055/136] 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 056/136] 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 057/136] 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 058/136] 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 059/136] 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 060/136] 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 061/136] 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 062/136] 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 063/136] 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 064/136] 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 065/136] 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 066/136] 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 067/136] 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 068/136] 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 069/136] 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 070/136] 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 071/136] 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 072/136] 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 073/136] 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 074/136] 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 075/136] 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 076/136] 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 077/136] 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 078/136] 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 079/136] 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 080/136] 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 081/136] 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 082/136] 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 083/136] 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 084/136] 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 085/136] 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 086/136] 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 087/136] 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 088/136] 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 089/136] 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 090/136] 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 091/136] 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 092/136] 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 093/136] 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 094/136] 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 095/136] 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 096/136] 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 097/136] 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 098/136] 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 099/136] 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, From 70b5b3be7baeaf35e654968ecef0625ea754e10e Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Thu, 7 May 2026 21:22:18 +0300 Subject: [PATCH 100/136] Add a "checkbox" indicator to the message in the selection mode --- .../ui/message/ConversationMessage.kt | 98 +------ .../ui/message/ConversationMessageRows.kt | 207 +++++++++++++++ .../ConversationMessageSelectionIndicator.kt | 249 ++++++++++++++++++ 3 files changed, 465 insertions(+), 89 deletions(-) create mode 100644 src/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessageRows.kt create mode 100644 src/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessageSelectionIndicator.kt 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 d963f4d9..a48e6570 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 @@ -2,28 +2,21 @@ package com.android.messaging.ui.conversation.messages.ui.message import android.content.Context import android.text.format.DateUtils -import androidx.compose.foundation.combinedClickable 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.widthIn import androidx.compose.foundation.shape.RoundedCornerShape 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.draw.clip -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.unit.Dp import androidx.compose.ui.unit.dp import com.android.messaging.R @@ -231,104 +224,31 @@ private fun ConversationMessageContent( onMessageLongClick: () -> Unit, onMessageResendClick: () -> Unit, ) { - val bubbleInteractionModifier = conversationMessageBubbleInteractionModifier( - message = message, - isSelected = isSelected, - isSelectionMode = isSelectionMode, - layout = layout, - onMessageClick = onMessageClick, - onMessageLongClick = onMessageLongClick, - onMessageResendClick = onMessageResendClick, - ) - Column( - modifier = Modifier.widthIn(max = maxBubbleWidth), horizontalAlignment = messageContentHorizontalAlignment(message = message), ) { - ConversationMessageBubble( - modifier = bubbleInteractionModifier, + ConversationMessageBubbleRow( message = message, isSelected = isSelected, isSelectionMode = isSelectionMode, layout = layout, maxBubbleWidth = maxBubbleWidth, - onAttachmentClick = { contentType, contentUri -> - when { - isSelectionMode -> { - onMessageClick() - } - - message.canResendMessage -> { - onMessageResendClick() - } - - else -> { - onAttachmentClick(contentType, contentUri) - } - } - }, - onExternalUriClick = { uri -> - when { - isSelectionMode -> { - onMessageClick() - } - - message.canResendMessage -> { - onMessageResendClick() - } - - else -> { - onExternalUriClick(uri) - } - } - }, + onAttachmentClick = onAttachmentClick, + onExternalUriClick = onExternalUriClick, + onMessageClick = onMessageClick, onMessageLongClick = onMessageLongClick, + onMessageResendClick = onMessageResendClick, ) - ConversationMessageMetadata( + ConversationMessageMetadataRow( message = message, - metadataText = layout.metadataText, + isSelectionMode = isSelectionMode, + layout = layout, + maxBubbleWidth = maxBubbleWidth, ) } } -@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/messages/ui/message/ConversationMessageRows.kt b/src/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessageRows.kt new file mode 100644 index 00000000..66252d5a --- /dev/null +++ b/src/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessageRows.kt @@ -0,0 +1,207 @@ +package com.android.messaging.ui.conversation.messages.ui.message + +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.interaction.MutableInteractionSource +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.widthIn +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.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.selected +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.Dp +import com.android.messaging.ui.conversation.messages.model.message.ConversationMessageUiModel + +@Composable +internal fun ConversationMessageBubbleRow( + message: ConversationMessageUiModel, + isSelected: Boolean, + isSelectionMode: Boolean, + layout: ConversationMessageLayout, + maxBubbleWidth: Dp, + onAttachmentClick: (contentType: String, contentUri: String) -> Unit, + onExternalUriClick: (String) -> Unit, + onMessageClick: () -> Unit, + onMessageLongClick: () -> Unit, + onMessageResendClick: () -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .conversationMessageSelectionModeRowModifier( + isSelected = isSelected, + isSelectionMode = isSelectionMode, + onMessageClick = onMessageClick, + onMessageLongClick = onMessageLongClick, + ), + verticalAlignment = Alignment.CenterVertically, + ) { + ConversationMessageSelectionIndicator( + visible = isSelectionMode, + isSelected = isSelected, + expandFrom = Alignment.Start, + shrinkTowards = Alignment.Start, + ) + + Row( + modifier = Modifier.weight(weight = 1f), + horizontalArrangement = conversationMessageRowHorizontalArrangement( + message = message, + ), + verticalAlignment = Alignment.CenterVertically, + ) { + ConversationMessageBubble( + modifier = Modifier.conversationMessageBubbleInteractionModifier( + message = message, + isSelectionMode = isSelectionMode, + layout = layout, + onMessageLongClick = onMessageLongClick, + onMessageResendClick = onMessageResendClick, + ), + message = message, + isSelected = isSelected, + isSelectionMode = isSelectionMode, + layout = layout, + maxBubbleWidth = maxBubbleWidth, + onAttachmentClick = { contentType, contentUri -> + when { + isSelectionMode -> onMessageClick() + message.canResendMessage -> onMessageResendClick() + else -> onAttachmentClick(contentType, contentUri) + } + }, + onExternalUriClick = { uri -> + when { + isSelectionMode -> onMessageClick() + message.canResendMessage -> onMessageResendClick() + else -> onExternalUriClick(uri) + } + }, + onMessageLongClick = onMessageLongClick, + ) + } + } +} + +private fun conversationMessageRowHorizontalArrangement( + message: ConversationMessageUiModel, +): Arrangement.Horizontal { + return when { + message.isIncoming -> Arrangement.Start + else -> Arrangement.End + } +} + +@Composable +private fun Modifier.conversationMessageSelectionModeRowModifier( + isSelected: Boolean, + isSelectionMode: Boolean, + onMessageClick: () -> Unit, + onMessageLongClick: () -> Unit, +): Modifier { + val hapticFeedback = LocalHapticFeedback.current + val interactionSource = remember { MutableInteractionSource() } + return when { + !isSelectionMode -> this + + else -> { + this + .semantics { + role = Role.Checkbox + selected = isSelected + } + .combinedClickable( + interactionSource = interactionSource, + indication = null, + enabled = true, + onClick = { + hapticFeedback.performHapticFeedback(HapticFeedbackType.ContextClick) + onMessageClick() + }, + onLongClick = { + hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) + onMessageLongClick() + }, + ) + } + } +} + +@Composable +private fun Modifier.conversationMessageBubbleInteractionModifier( + message: ConversationMessageUiModel, + isSelectionMode: Boolean, + layout: ConversationMessageLayout, + onMessageLongClick: () -> Unit, + onMessageResendClick: () -> Unit, +): Modifier { + val hapticFeedback = LocalHapticFeedback.current + val bubbleModifier = this + .clip(shape = layout.bubbleShape) + + return when { + isSelectionMode -> bubbleModifier + + else -> { + bubbleModifier.combinedClickable( + enabled = true, + onClick = { + if (message.canResendMessage) { + onMessageResendClick() + } + }, + onLongClick = { + hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) + onMessageLongClick() + }, + ) + } + } +} + +@Composable +internal fun ConversationMessageMetadataRow( + message: ConversationMessageUiModel, + isSelectionMode: Boolean, + layout: ConversationMessageLayout, + maxBubbleWidth: Dp, +) { + Row( + modifier = Modifier.fillMaxWidth(), + ) { + ConversationMessageSelectionIndicatorOffset( + visible = isSelectionMode, + expandFrom = Alignment.Start, + shrinkTowards = Alignment.Start, + ) + + Row( + modifier = Modifier.weight(weight = 1f), + horizontalArrangement = conversationMessageRowHorizontalArrangement( + message = message, + ), + ) { + Column( + modifier = Modifier.widthIn(max = maxBubbleWidth), + horizontalAlignment = when { + message.isIncoming -> Alignment.Start + else -> Alignment.End + }, + ) { + ConversationMessageMetadata( + message = message, + metadataText = layout.metadataText, + ) + } + } + } +} diff --git a/src/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessageSelectionIndicator.kt b/src/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessageSelectionIndicator.kt new file mode 100644 index 00000000..30358e84 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessageSelectionIndicator.kt @@ -0,0 +1,249 @@ +package com.android.messaging.ui.conversation.messages.ui.message + +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.animateFloat +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.animation.core.updateTransition +import androidx.compose.animation.expandHorizontally +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.animation.shrinkHorizontally +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Spacer +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.Check +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +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 + +private val MESSAGE_SELECTION_INDICATOR_TOUCH_SIZE = 48.dp +private val MESSAGE_SELECTION_INDICATOR_SIZE = 22.dp +private val MESSAGE_SELECTION_INDICATOR_CHECK_SIZE = 16.dp +private val MESSAGE_SELECTION_INDICATOR_BORDER_WIDTH = 2.dp + +@Composable +internal fun ConversationMessageSelectionIndicator( + visible: Boolean, + isSelected: Boolean, + expandFrom: Alignment.Horizontal, + shrinkTowards: Alignment.Horizontal, +) { + AnimatedVisibility( + visible = visible, + enter = fadeIn( + animationSpec = tween( + durationMillis = 200, + easing = FastOutSlowInEasing, + ), + ) + scaleIn( + animationSpec = spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = Spring.StiffnessMediumLow, + ), + initialScale = 0.8f, + ) + expandHorizontally( + animationSpec = tween( + durationMillis = 200, + easing = FastOutSlowInEasing, + ), + expandFrom = expandFrom, + ), + exit = fadeOut( + animationSpec = tween( + durationMillis = 150, + easing = FastOutSlowInEasing, + ), + ) + scaleOut( + animationSpec = spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = Spring.StiffnessMediumLow, + ), + targetScale = 0.8f, + ) + shrinkHorizontally( + animationSpec = tween( + durationMillis = 150, + easing = FastOutSlowInEasing, + ), + shrinkTowards = shrinkTowards, + ), + label = "conversationMessageSelectionIndicatorVisibility", + ) { + ConversationMessageSelectionIndicatorContent( + isSelected = isSelected, + ) + } +} + +@Composable +private fun ConversationMessageSelectionIndicatorContent( + isSelected: Boolean, +) { + val selectionTransition = updateTransition( + targetState = isSelected, + label = "conversationMessageSelectionIndicator", + ) + + val containerColor by selectionTransition.animateSelectionIndicatorContainerColor() + val borderColor by selectionTransition.animateSelectionIndicatorBorderColor() + val checkmarkAlpha by selectionTransition.animateSelectionIndicatorCheckmarkAlpha() + val checkmarkScale by selectionTransition.animateSelectionIndicatorCheckmarkScale() + + Box( + modifier = Modifier.size(size = MESSAGE_SELECTION_INDICATOR_TOUCH_SIZE), + contentAlignment = Alignment.Center, + ) { + Box( + modifier = Modifier + .size(size = MESSAGE_SELECTION_INDICATOR_SIZE) + .background( + color = containerColor, + shape = CircleShape, + ) + .border( + width = MESSAGE_SELECTION_INDICATOR_BORDER_WIDTH, + color = borderColor, + shape = CircleShape, + ), + contentAlignment = Alignment.Center, + ) { + Icon( + modifier = Modifier + .size(size = MESSAGE_SELECTION_INDICATOR_CHECK_SIZE) + .graphicsLayer { + alpha = checkmarkAlpha + scaleX = checkmarkScale + scaleY = checkmarkScale + }, + imageVector = Icons.Rounded.Check, + contentDescription = null, + tint = MaterialTheme.colorScheme.onPrimary, + ) + } + } +} + +@Composable +internal fun ConversationMessageSelectionIndicatorOffset( + visible: Boolean, + expandFrom: Alignment.Horizontal, + shrinkTowards: Alignment.Horizontal, +) { + AnimatedVisibility( + visible = visible, + enter = expandHorizontally( + animationSpec = tween( + durationMillis = 200, + easing = FastOutSlowInEasing, + ), + expandFrom = expandFrom, + ), + exit = shrinkHorizontally( + animationSpec = tween( + durationMillis = 150, + easing = FastOutSlowInEasing, + ), + shrinkTowards = shrinkTowards, + ), + label = "conversationMessageSelectionIndicatorOffset", + ) { + Spacer( + modifier = Modifier + .width(width = MESSAGE_SELECTION_INDICATOR_TOUCH_SIZE), + ) + } +} + +@Composable +private fun Transition.animateSelectionIndicatorContainerColor(): State { + return animateColor( + transitionSpec = { + tween( + durationMillis = 180, + easing = FastOutSlowInEasing, + ) + }, + label = "conversationMessageSelectionIndicatorContainerColor", + targetValueByState = { indicatorSelected -> + when { + indicatorSelected -> MaterialTheme.colorScheme.primary + else -> Color.Transparent + } + }, + ) +} + +@Composable +private fun Transition.animateSelectionIndicatorBorderColor(): State { + return animateColor( + transitionSpec = { + tween( + durationMillis = 180, + easing = FastOutSlowInEasing, + ) + }, + label = "conversationMessageSelectionIndicatorBorderColor", + targetValueByState = { indicatorSelected -> + when { + indicatorSelected -> MaterialTheme.colorScheme.primary + else -> MaterialTheme.colorScheme.outline + } + }, + ) +} + +@Composable +private fun Transition.animateSelectionIndicatorCheckmarkAlpha(): State { + return animateFloat( + transitionSpec = { + tween( + durationMillis = 180, + easing = FastOutSlowInEasing, + ) + }, + label = "conversationMessageSelectionIndicatorCheckmarkAlpha", + targetValueByState = { indicatorSelected -> + when { + indicatorSelected -> 1f + else -> 0f + } + }, + ) +} + +@Composable +private fun Transition.animateSelectionIndicatorCheckmarkScale(): State { + return animateFloat( + transitionSpec = { + spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = Spring.StiffnessMediumLow, + ) + }, + label = "conversationMessageSelectionIndicatorCheckmarkScale", + targetValueByState = { indicatorSelected -> + when { + indicatorSelected -> 1f + else -> 0.7f + } + }, + ) +} From 99b7c7d0884c3e6b39bbaf9ce8378f0ae0ad6e1c Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Thu, 7 May 2026 21:44:46 +0300 Subject: [PATCH 101/136] Fix links colors --- .../messages/ui/message/ConversationMessageBubble.kt | 7 +------ 1 file changed, 1 insertion(+), 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 5943077c..c91d2e7b 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 @@ -193,11 +193,6 @@ private fun ConversationMessageBubbleSurface( isSelected = isSelected, ) - val linkColor = when { - isSelected -> contentColor - else -> MaterialTheme.colorScheme.primary - } - Surface( color = messageBubbleColor( message = message, @@ -208,7 +203,7 @@ private fun ConversationMessageBubbleSurface( modifier = modifier, ) { CompositionLocalProvider( - LocalConversationMessageLinkColor provides linkColor, + LocalConversationMessageLinkColor provides contentColor, ) { bubbleContent() } From e3f5deff306095db9b8a83d207ac76d4397ddc7c Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Fri, 8 May 2026 22:01:08 +0300 Subject: [PATCH 102/136] Validate MMS attachments limit before sending and attaching --- .../ResolveDraftAttachmentsWithinLimitTest.kt | 111 +++++ .../ConversationSubscriptionsRepository.kt | 13 + .../ConversationViewModelBindsModule.kt | 24 + .../ResolveConversationDraftSendProtocol.kt | 91 ++++ .../ResolveDraftAttachmentsWithinLimit.kt | 58 +++ .../usecase/draft/SendConversationDraft.kt | 47 ++ .../SendConversationDraftException.kt | 6 + .../draft/model/DraftAttachmentLimitResult.kt | 8 + .../ConversationAudioRecordingDelegate.kt | 11 +- .../delegate/ConversationDraftDelegate.kt | 445 +++++++---------- .../ConversationDraftEditorDelegate.kt | 449 ++++++++++++++++++ .../delegate/ConversationDraftEditorState.kt | 10 +- .../mediapicker/ConversationMediaPicker.kt | 15 +- .../ConversationMediaPickerCaptureRoute.kt | 29 +- .../ConversationMediaPickerCaptureScene.kt | 2 + .../ConversationMediaPickerOverlay.kt | 2 + .../ConversationMediaPickerScaffold.kt | 4 + .../camera/ConversationMediaPickerActions.kt | 10 + .../ConversationMediaPickerDelegate.kt | 108 ++++- .../conversation/screen/ConversationScreen.kt | 98 ---- .../screen/ConversationScreenDialogs.kt | 170 +++++++ .../screen/ConversationScreenRoute.kt | 43 +- .../screen/ConversationViewModel.kt | 46 +- .../ConversationAttachmentLimitWarning.kt | 12 + .../ConversationScreenScaffoldUiState.kt | 1 + 25 files changed, 1356 insertions(+), 457 deletions(-) create mode 100644 app/src/test/kotlin/com/android/messaging/domain/conversation/usecase/draft/ResolveDraftAttachmentsWithinLimitTest.kt create mode 100644 src/com/android/messaging/domain/conversation/usecase/draft/ResolveConversationDraftSendProtocol.kt create mode 100644 src/com/android/messaging/domain/conversation/usecase/draft/ResolveDraftAttachmentsWithinLimit.kt create mode 100644 src/com/android/messaging/domain/conversation/usecase/draft/model/DraftAttachmentLimitResult.kt create mode 100644 src/com/android/messaging/ui/conversation/composer/delegate/ConversationDraftEditorDelegate.kt create mode 100644 src/com/android/messaging/ui/conversation/screen/ConversationScreenDialogs.kt create mode 100644 src/com/android/messaging/ui/conversation/screen/model/ConversationAttachmentLimitWarning.kt diff --git a/app/src/test/kotlin/com/android/messaging/domain/conversation/usecase/draft/ResolveDraftAttachmentsWithinLimitTest.kt b/app/src/test/kotlin/com/android/messaging/domain/conversation/usecase/draft/ResolveDraftAttachmentsWithinLimitTest.kt new file mode 100644 index 00000000..558c9a98 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/domain/conversation/usecase/draft/ResolveDraftAttachmentsWithinLimitTest.kt @@ -0,0 +1,111 @@ +package com.android.messaging.domain.conversation.usecase.draft + +import com.android.messaging.data.conversation.model.draft.ConversationDraftAttachment +import com.android.messaging.data.conversation.model.metadata.ConversationSubscription +import com.android.messaging.data.conversation.repository.ConversationSubscriptionsRepository +import kotlinx.collections.immutable.ImmutableList +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emptyFlow +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class ResolveDraftAttachmentsWithinLimitTest { + + @Test + fun invoke_addsUntilLimitAndDropsOverflow() { + val currentAttachments = listOf( + attachment(contentUri = "content://attachment/1"), + ) + val attachmentsToAdd = listOf( + attachment(contentUri = "content://attachment/2"), + attachment(contentUri = "content://attachment/3"), + attachment(contentUri = "content://attachment/4"), + ) + + val result = createResolveDraftAttachmentsWithinLimit(attachmentLimit = 3)( + currentAttachments = currentAttachments, + attachmentsToAdd = attachmentsToAdd, + ) + + assertEquals( + listOf( + attachment(contentUri = "content://attachment/2"), + attachment(contentUri = "content://attachment/3"), + ), + result.attachmentsToAdd, + ) + assertTrue(result.didDropAttachments) + } + + @Test + fun invoke_ignoresDuplicatesWithoutWarning() { + val currentAttachments = listOf( + attachment(contentUri = "content://attachment/1"), + ) + val attachmentsToAdd = listOf( + attachment(contentUri = "content://attachment/1"), + ) + + val result = createResolveDraftAttachmentsWithinLimit(attachmentLimit = 1)( + currentAttachments = currentAttachments, + attachmentsToAdd = attachmentsToAdd, + ) + + assertEquals(emptyList(), result.attachmentsToAdd) + assertFalse(result.didDropAttachments) + } + + @Test + fun invoke_exactLimitDoesNotWarn() { + val currentAttachments = listOf( + attachment(contentUri = "content://attachment/1"), + ) + val attachmentsToAdd = listOf( + attachment(contentUri = "content://attachment/2"), + ) + + val result = createResolveDraftAttachmentsWithinLimit(attachmentLimit = 2)( + currentAttachments = currentAttachments, + attachmentsToAdd = attachmentsToAdd, + ) + + assertEquals(attachmentsToAdd, result.attachmentsToAdd) + assertFalse(result.didDropAttachments) + } + + private fun attachment(contentUri: String): ConversationDraftAttachment { + return ConversationDraftAttachment( + contentType = "image/jpeg", + contentUri = contentUri, + ) + } + + private fun createResolveDraftAttachmentsWithinLimit( + attachmentLimit: Int, + ): ResolveDraftAttachmentsWithinLimit { + return ResolveDraftAttachmentsWithinLimitImpl( + conversationSubscriptionsRepository = FakeConversationSubscriptionsRepository( + attachmentLimit = attachmentLimit, + ), + ) + } + + private class FakeConversationSubscriptionsRepository( + private val attachmentLimit: Int, + ) : ConversationSubscriptionsRepository { + + override fun observeActiveSubscriptions(): Flow> { + return emptyFlow() + } + + override fun resolveAttachmentLimit(): Int { + return attachmentLimit + } + + override fun resolveMaxMessageSize(selfParticipantId: String): Flow { + return emptyFlow() + } + } +} diff --git a/src/com/android/messaging/data/conversation/repository/ConversationSubscriptionsRepository.kt b/src/com/android/messaging/data/conversation/repository/ConversationSubscriptionsRepository.kt index c94cf87f..a2096d7f 100644 --- a/src/com/android/messaging/data/conversation/repository/ConversationSubscriptionsRepository.kt +++ b/src/com/android/messaging/data/conversation/repository/ConversationSubscriptionsRepository.kt @@ -12,6 +12,8 @@ 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.BugleGservices +import com.android.messaging.util.BugleGservicesKeys import com.android.messaging.util.LogUtil import com.android.messaging.util.core.extension.typedFlow import javax.inject.Inject @@ -32,6 +34,8 @@ import kotlinx.coroutines.flow.map internal interface ConversationSubscriptionsRepository { fun observeActiveSubscriptions(): Flow> + fun resolveAttachmentLimit(): Int + fun resolveMaxMessageSize(selfParticipantId: String): Flow } @@ -63,6 +67,15 @@ internal class ConversationSubscriptionsRepositoryImpl @Inject constructor( } } + override fun resolveAttachmentLimit(): Int { + return BugleGservices + .get() + .getInt( + BugleGservicesKeys.MMS_ATTACHMENT_LIMIT, + BugleGservicesKeys.MMS_ATTACHMENT_LIMIT_DEFAULT, + ) + } + override fun resolveMaxMessageSize(selfParticipantId: String): Flow { return typedFlow { queryMaxMessageSize(selfParticipantId = selfParticipantId) diff --git a/src/com/android/messaging/di/conversation/ConversationViewModelBindsModule.kt b/src/com/android/messaging/di/conversation/ConversationViewModelBindsModule.kt index 30a25d68..0ca2bbc3 100644 --- a/src/com/android/messaging/di/conversation/ConversationViewModelBindsModule.kt +++ b/src/com/android/messaging/di/conversation/ConversationViewModelBindsModule.kt @@ -1,11 +1,17 @@ package com.android.messaging.di.conversation +import com.android.messaging.domain.conversation.usecase.draft.ResolveConversationDraftSendProtocol +import com.android.messaging.domain.conversation.usecase.draft.ResolveConversationDraftSendProtocolImpl +import com.android.messaging.domain.conversation.usecase.draft.ResolveDraftAttachmentsWithinLimit +import com.android.messaging.domain.conversation.usecase.draft.ResolveDraftAttachmentsWithinLimitImpl 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.composer.delegate.ConversationDraftEditorDelegate +import com.android.messaging.ui.conversation.composer.delegate.ConversationDraftEditorDelegateImpl 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 @@ -40,12 +46,30 @@ internal abstract class ConversationViewModelBindsModule { impl: ConversationComposerAttachmentsDelegateImpl, ): ConversationComposerAttachmentsDelegate + @Binds + @ViewModelScoped + abstract fun bindResolveConversationDraftSendProtocol( + impl: ResolveConversationDraftSendProtocolImpl, + ): ResolveConversationDraftSendProtocol + + @Binds + @ViewModelScoped + abstract fun bindResolveDraftAttachmentsWithinLimit( + impl: ResolveDraftAttachmentsWithinLimitImpl, + ): ResolveDraftAttachmentsWithinLimit + @Binds @ViewModelScoped abstract fun bindConversationDraftDelegate( impl: ConversationDraftDelegateImpl, ): ConversationDraftDelegate + @Binds + @ViewModelScoped + abstract fun bindConversationDraftEditorDelegate( + impl: ConversationDraftEditorDelegateImpl, + ): ConversationDraftEditorDelegate + @Binds @ViewModelScoped abstract fun bindConversationMediaPickerDelegate( diff --git a/src/com/android/messaging/domain/conversation/usecase/draft/ResolveConversationDraftSendProtocol.kt b/src/com/android/messaging/domain/conversation/usecase/draft/ResolveConversationDraftSendProtocol.kt new file mode 100644 index 00000000..e98e25b5 --- /dev/null +++ b/src/com/android/messaging/domain/conversation/usecase/draft/ResolveConversationDraftSendProtocol.kt @@ -0,0 +1,91 @@ +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.data.conversation.repository.ConversationsRepository +import com.android.messaging.di.core.IoDispatcher +import com.android.messaging.domain.conversation.usecase.draft.model.ConversationDraftSendProtocol +import com.android.messaging.util.LogUtil +import javax.inject.Inject +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.withContext + +internal interface ResolveConversationDraftSendProtocol { + suspend operator fun invoke( + conversationId: String?, + draft: ConversationDraft, + ): ConversationDraftSendProtocol +} + +internal class ResolveConversationDraftSendProtocolImpl @Inject constructor( + private val conversationsRepository: ConversationsRepository, + private val getConversationDraftSendProtocol: GetConversationDraftSendProtocol, + @param:IoDispatcher + private val ioDispatcher: CoroutineDispatcher, +) : ResolveConversationDraftSendProtocol { + + @Suppress("TooGenericExceptionCaught") + override suspend operator fun invoke( + conversationId: String?, + draft: ConversationDraft, + ): ConversationDraftSendProtocol { + return try { + val sendData = resolveConversationSendData( + conversationId = conversationId, + draft = draft, + ) + + when (sendData) { + null -> fallbackDraftSendProtocol(draft = draft) + else -> { + getConversationDraftSendProtocol( + draft = draft, + sendData = sendData, + ) + } + } + } catch (exception: CancellationException) { + throw exception + } catch (exception: Exception) { + LogUtil.e( + TAG, + "Failed to resolve draft send protocol for conversation $conversationId", + exception, + ) + + fallbackDraftSendProtocol(draft = draft) + } + } + + private suspend fun resolveConversationSendData( + conversationId: String?, + draft: ConversationDraft, + ): ConversationSendData? { + return when { + draft.hasContent && !conversationId.isNullOrBlank() -> { + withContext(context = ioDispatcher) { + conversationsRepository.getConversationSendData( + conversationId = conversationId, + requestedSelfParticipantId = draft.selfParticipantId, + ) + } + } + + else -> null + } + } + + private fun fallbackDraftSendProtocol( + draft: ConversationDraft, + ): ConversationDraftSendProtocol { + return when { + draft.isMms -> ConversationDraftSendProtocol.MMS + else -> ConversationDraftSendProtocol.SMS + } + } + + private companion object { + private const val TAG = "ResolveDraftSendProtocol" + } +} diff --git a/src/com/android/messaging/domain/conversation/usecase/draft/ResolveDraftAttachmentsWithinLimit.kt b/src/com/android/messaging/domain/conversation/usecase/draft/ResolveDraftAttachmentsWithinLimit.kt new file mode 100644 index 00000000..a9f4d381 --- /dev/null +++ b/src/com/android/messaging/domain/conversation/usecase/draft/ResolveDraftAttachmentsWithinLimit.kt @@ -0,0 +1,58 @@ +package com.android.messaging.domain.conversation.usecase.draft + +import com.android.messaging.data.conversation.model.draft.ConversationDraftAttachment +import com.android.messaging.data.conversation.repository.ConversationSubscriptionsRepository +import com.android.messaging.domain.conversation.usecase.draft.model.DraftAttachmentLimitResult +import javax.inject.Inject + +internal interface ResolveDraftAttachmentsWithinLimit { + operator fun invoke( + currentAttachments: Collection, + attachmentsToAdd: Collection, + ): DraftAttachmentLimitResult +} + +internal class ResolveDraftAttachmentsWithinLimitImpl @Inject constructor( + private val conversationSubscriptionsRepository: ConversationSubscriptionsRepository, +) : ResolveDraftAttachmentsWithinLimit { + + override operator fun invoke( + currentAttachments: Collection, + attachmentsToAdd: Collection, + ): DraftAttachmentLimitResult { + return resolveAttachmentsWithinLimit( + currentAttachments = currentAttachments, + attachmentsToAdd = attachmentsToAdd, + attachmentLimit = conversationSubscriptionsRepository.resolveAttachmentLimit(), + ) + } + + private fun resolveAttachmentsWithinLimit( + currentAttachments: Collection, + attachmentsToAdd: Collection, + attachmentLimit: Int, + ): DraftAttachmentLimitResult { + val remainingAttachmentSlots = (attachmentLimit - currentAttachments.size) + .coerceAtLeast(0) + + val currentAttachmentContentUris = currentAttachments + .map { attachment -> attachment.contentUri } + .toSet() + + val uniqueAttachmentsToAdd = attachmentsToAdd + .asSequence() + .distinctBy { attachment -> attachment.contentUri } + .filterNot { attachment -> + attachment.contentUri in currentAttachmentContentUris + } + .toList() + + val acceptedAttachments = uniqueAttachmentsToAdd + .take(n = remainingAttachmentSlots) + + return DraftAttachmentLimitResult( + attachmentsToAdd = acceptedAttachments, + didDropAttachments = uniqueAttachmentsToAdd.size > acceptedAttachments.size, + ) + } +} 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 9241627e..7a1c12f2 100644 --- a/src/com/android/messaging/domain/conversation/usecase/draft/SendConversationDraft.kt +++ b/src/com/android/messaging/domain/conversation/usecase/draft/SendConversationDraft.kt @@ -4,6 +4,7 @@ import com.android.messaging.data.conversation.mapper.ConversationDraftMessageDa 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.ConversationSubscriptionsRepository import com.android.messaging.data.conversation.repository.ConversationsRepository import com.android.messaging.datamodel.action.InsertNewMessageAction import com.android.messaging.datamodel.data.MessageData @@ -14,11 +15,13 @@ import com.android.messaging.domain.conversation.usecase.draft.exception.Convers 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.MessageLimitExceededException 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.MmsConfig import com.android.messaging.sms.MmsUtils import com.android.messaging.util.ContentType import com.android.messaging.util.PhoneUtils @@ -33,11 +36,13 @@ internal interface SendConversationDraft { operator fun invoke( conversationId: String, draft: ConversationDraft, + ignoreMessageSizeLimit: Boolean = false, ): Flow } internal class SendConversationDraftImpl @Inject constructor( private val conversationsRepository: ConversationsRepository, + private val conversationSubscriptionsRepository: ConversationSubscriptionsRepository, private val getConversationDraftSendProtocol: GetConversationDraftSendProtocol, private val conversationDraftMessageDataMapper: ConversationDraftMessageDataMapper, @param:IoDispatcher @@ -48,12 +53,14 @@ internal class SendConversationDraftImpl @Inject constructor( override operator fun invoke( conversationId: String, draft: ConversationDraft, + ignoreMessageSizeLimit: Boolean, ): Flow { return unitFlow { try { validateAndSendDraft( conversationId = conversationId, draft = draft, + ignoreMessageSizeLimit = ignoreMessageSizeLimit, ) } catch (exception: Exception) { if (exception is CancellationException) { @@ -85,6 +92,7 @@ internal class SendConversationDraftImpl @Inject constructor( private fun validateAndSendDraft( conversationId: String, draft: ConversationDraft, + ignoreMessageSizeLimit: Boolean, ) { validateDraftBasics( conversationId = conversationId, @@ -121,6 +129,13 @@ internal class SendConversationDraftImpl @Inject constructor( message.consolidateText() + validateMappedMessageForSend( + conversationId = conversationId, + message = message, + selfSubId = selfSubId, + ignoreMessageSizeLimit = ignoreMessageSizeLimit, + ) + insertNewMessageWithLegacySelfLock( message = message, sendData = sendData, @@ -232,6 +247,38 @@ internal class SendConversationDraftImpl @Inject constructor( } } + private fun validateMappedMessageForSend( + conversationId: String, + message: MessageData, + selfSubId: Int, + ignoreMessageSizeLimit: Boolean, + ) { + if (ignoreMessageSizeLimit) { + return + } + + val attachments = message.parts.filter { part -> part.isAttachment } + + if (attachments.size > conversationSubscriptionsRepository.resolveAttachmentLimit()) { + throw MessageLimitExceededException(conversationId = conversationId) + } + + val totalAttachmentSize = attachments.sumOf { attachment -> + attachment.minimumSizeInBytesForSending + } + + if (totalAttachmentSize > resolveMaxMessageSize(selfSubId = selfSubId)) { + throw MessageLimitExceededException(conversationId = conversationId) + } + } + + private fun resolveMaxMessageSize(selfSubId: Int): Int { + return when { + selfSubId <= ParticipantData.DEFAULT_SELF_SUB_ID -> MmsConfig.getMaxMaxMessageSize() + else -> MmsConfig.get(selfSubId).maxMessageSize + } + } + private fun insertNewMessageWithLegacySelfLock( message: MessageData, sendData: ConversationSendData, 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 index 465e625f..368b2960 100644 --- a/src/com/android/messaging/domain/conversation/usecase/draft/exception/SendConversationDraftException.kt +++ b/src/com/android/messaging/domain/conversation/usecase/draft/exception/SendConversationDraftException.kt @@ -54,6 +54,12 @@ internal class TooManyVideoAttachmentsException( "attachments.", ) +internal class MessageLimitExceededException( + conversationId: String, +) : SendConversationDraftException( + message = "Draft for conversation $conversationId exceeds the MMS message limit.", +) + internal class DraftDispatchFailedException( conversationId: String, cause: Throwable, diff --git a/src/com/android/messaging/domain/conversation/usecase/draft/model/DraftAttachmentLimitResult.kt b/src/com/android/messaging/domain/conversation/usecase/draft/model/DraftAttachmentLimitResult.kt new file mode 100644 index 00000000..e629abe1 --- /dev/null +++ b/src/com/android/messaging/domain/conversation/usecase/draft/model/DraftAttachmentLimitResult.kt @@ -0,0 +1,8 @@ +package com.android.messaging.domain.conversation.usecase.draft.model + +import com.android.messaging.data.conversation.model.draft.ConversationDraftAttachment + +internal data class DraftAttachmentLimitResult( + val attachmentsToAdd: List, + val didDropAttachments: Boolean, +) diff --git a/src/com/android/messaging/ui/conversation/audio/delegate/ConversationAudioRecordingDelegate.kt b/src/com/android/messaging/ui/conversation/audio/delegate/ConversationAudioRecordingDelegate.kt index acc2f590..2263e292 100644 --- a/src/com/android/messaging/ui/conversation/audio/delegate/ConversationAudioRecordingDelegate.kt +++ b/src/com/android/messaging/ui/conversation/audio/delegate/ConversationAudioRecordingDelegate.kt @@ -475,11 +475,15 @@ internal class ConversationAudioRecordingDelegateImpl @Inject constructor( return } - resolvePendingAudioAttachment( + val didResolvePendingAttachment = resolvePendingAudioAttachment( pendingAttachmentId = pendingAttachmentId, outputUri = outputUri, ) + if (!didResolvePendingAttachment) { + deleteStoppedRecording(outputUri = outputUri) + } + withSessionStateLock { clearFinalizingSessionLocked(pendingAttachmentId = pendingAttachmentId) } @@ -539,7 +543,7 @@ internal class ConversationAudioRecordingDelegateImpl @Inject constructor( private fun resolvePendingAudioAttachment( pendingAttachmentId: String, outputUri: Uri?, - ) { + ): Boolean { val recordedAttachment = outputUri?.let { resolvedOutputUri -> ConversationDraftAttachment( contentType = ContentType.AUDIO_3GPP, @@ -547,11 +551,12 @@ internal class ConversationAudioRecordingDelegateImpl @Inject constructor( ) } - when (recordedAttachment) { + return when (recordedAttachment) { null -> { conversationDraftDelegate.removePendingAttachment( pendingAttachmentId = pendingAttachmentId, ) + false } else -> { 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 ff0bb2d3..e199b3df 100644 --- a/src/com/android/messaging/ui/conversation/composer/delegate/ConversationDraftDelegate.kt +++ b/src/com/android/messaging/ui/conversation/composer/delegate/ConversationDraftDelegate.kt @@ -6,21 +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.MessageLimitExceededException 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.common.ConversationScreenDelegate import com.android.messaging.ui.conversation.composer.model.ConversationDraftState +import com.android.messaging.ui.conversation.screen.model.ConversationAttachmentLimitWarning import com.android.messaging.ui.conversation.screen.model.ConversationScreenEffect import com.android.messaging.util.LogUtil import com.android.messaging.util.core.extension.unitFlow @@ -45,11 +43,9 @@ 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 -import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock @@ -57,6 +53,7 @@ import kotlinx.coroutines.withContext internal interface ConversationDraftDelegate : ConversationScreenDelegate { val effects: Flow + val attachmentLimitWarning: StateFlow fun onMessageTextChanged(messageText: String) @@ -67,7 +64,11 @@ internal interface ConversationDraftDelegate : ConversationScreenDelegate) + fun addAttachments( + attachments: Collection, + ): List + + fun tryStartAddingAttachment(): Boolean fun addPendingAttachment(pendingAttachment: ConversationDraftPendingAttachment) @@ -78,7 +79,7 @@ internal interface ConversationDraftDelegate : ConversationScreenDelegate( extraBufferCapacity = 1, ) - private val _state = MutableStateFlow(ConversationDraftState()) + private val _attachmentLimitWarning = MutableStateFlow( + value = null, + ) + override val effects = _effects.asSharedFlow() - override val state = _state.asStateFlow() + override val attachmentLimitWarning = _attachmentLimitWarning.asStateFlow() + override val state: StateFlow = conversationDraftEditorDelegate.state - private val draftEditorState = MutableStateFlow(DraftEditorState()) private val draftSaveMutex = Mutex() private var boundScope: CoroutineScope? = null - private var pendingDraftSeed: PendingDraftSeed? = null private var pendingDefaultSmsRoleSendRequest: DraftSendRequest? = null + private var pendingMessageLimitSendRequest: DraftSendRequest? = null override fun bind( scope: CoroutineScope, @@ -142,85 +147,115 @@ internal class ConversationDraftDelegateImpl @Inject constructor( } override fun onMessageTextChanged(messageText: String) { - updateDraftEditorState { currentDraftEditorState -> - currentDraftEditorState.withMessageText(messageText) - } + conversationDraftEditorDelegate.onMessageTextChanged(messageText = messageText) } override fun onSelfParticipantIdChanged(selfParticipantId: String) { - updateDraftEditorState { currentDraftEditorState -> - currentDraftEditorState.withSelfParticipantId(selfParticipantId = selfParticipantId) - } + conversationDraftEditorDelegate.onSelfParticipantIdChanged( + selfParticipantId = selfParticipantId, + ) } override fun seedDraft( conversationId: String, draft: ConversationDraft, ) { - pendingDraftSeed = PendingDraftSeed( + conversationDraftEditorDelegate.seedDraft( conversationId = conversationId, draft = draft, ) - applyPendingDraftSeedIfPossible() } - override fun addAttachments(attachments: Collection) { - if (attachments.isEmpty()) { - return + override fun addAttachments( + attachments: Collection, + ): List { + val attachmentLimitResult = conversationDraftEditorDelegate.addAttachments( + attachments = attachments, + ) + + if (attachmentLimitResult.didDropAttachments) { + showComposingAttachmentLimitWarning() } - updateDraftEditorState { currentDraftEditorState -> - currentDraftEditorState.withAttachmentsAdded(attachments) + return attachmentLimitResult.attachmentsToAdd + } + + override fun tryStartAddingAttachment(): Boolean { + val canStartAddingAttachment = conversationDraftEditorDelegate.tryStartAddingAttachment() + + if (!canStartAddingAttachment) { + showComposingAttachmentLimitWarning() } + + return canStartAddingAttachment } override fun addPendingAttachment(pendingAttachment: ConversationDraftPendingAttachment) { - updateDraftEditorState { currentDraftEditorState -> - currentDraftEditorState.withPendingAttachmentAdded(pendingAttachment) - } + conversationDraftEditorDelegate.addPendingAttachment( + pendingAttachment = pendingAttachment, + ) } override fun removeAttachment(contentUri: String) { - updateDraftEditorState { currentDraftEditorState -> - currentDraftEditorState.withAttachmentRemoved(contentUri) - } + conversationDraftEditorDelegate.removeAttachment(contentUri = contentUri) } override fun removePendingAttachment(pendingAttachmentId: String) { - updateDraftEditorState { currentDraftEditorState -> - currentDraftEditorState.withPendingAttachmentRemoved(pendingAttachmentId) - } + conversationDraftEditorDelegate.removePendingAttachment( + pendingAttachmentId = pendingAttachmentId, + ) } override fun resolvePendingAttachment( pendingAttachmentId: String, attachment: ConversationDraftAttachment, - ) { - updateDraftEditorState { currentDraftEditorState -> - currentDraftEditorState.withPendingAttachmentResolved( - pendingAttachmentId = pendingAttachmentId, - attachment = attachment, - ) + ): Boolean { + val resolution = conversationDraftEditorDelegate.resolvePendingAttachment( + pendingAttachmentId = pendingAttachmentId, + attachment = attachment, + ) + + if (resolution.didDropAttachments) { + showComposingAttachmentLimitWarning() } + + return resolution.didResolveAttachment } override fun updateAttachmentCaption( contentUri: String, captionText: String, ) { - updateDraftEditorState { currentDraftEditorState -> - currentDraftEditorState.withAttachmentCaption( - contentUri = contentUri, - captionText = captionText, - ) - } + conversationDraftEditorDelegate.updateAttachmentCaption( + contentUri = contentUri, + captionText = captionText, + ) } override fun onSendClick() { - createSendRequestOrNull() + conversationDraftEditorDelegate.createSendRequestOrNull() ?.let(::sendDraftWhenActionRequirementsSatisfied) } + override fun dismissAttachmentLimitWarning() { + _attachmentLimitWarning.value = null + } + + private fun showComposingAttachmentLimitWarning() { + _attachmentLimitWarning.value = ConversationAttachmentLimitWarning + .ComposingAttachmentLimitReached + } + + override fun sendAnywayAfterAttachmentLimitWarning() { + val sendRequest = pendingMessageLimitSendRequest ?: return + pendingMessageLimitSendRequest = null + _attachmentLimitWarning.value = null + + sendDraftWhenActionRequirementsSatisfied( + sendRequest = sendRequest.copy(ignoreMessageSizeLimit = true), + ) + } + override fun onDefaultSmsRoleRequestResult(resultCode: Int): Boolean { val sendRequest = pendingDefaultSmsRoleSendRequest ?: return false @@ -238,7 +273,7 @@ internal class ConversationDraftDelegateImpl @Inject constructor( override fun persistDraft() { val scope = boundScope ?: return - val saveRequest = draftEditorState.value.toSaveRequestOrNull() ?: return + val saveRequest = conversationDraftEditorDelegate.currentSaveRequest ?: return launchDraftOperation(scope = scope) { createSaveDraftOperationFlow( @@ -251,7 +286,7 @@ internal class ConversationDraftDelegateImpl @Inject constructor( } override fun flushDraft() { - val saveRequest = draftEditorState.value.toSaveRequestOrNull() ?: return + val saveRequest = conversationDraftEditorDelegate.currentSaveRequest ?: return launchDraftOperation(scope = applicationScope) { createSaveDraftOperationFlow( @@ -272,7 +307,7 @@ internal class ConversationDraftDelegateImpl @Inject constructor( draftSaveMutex.withLock { // Ignore debounced or queued saves that no longer reflect the current working draft if (shouldSkipIfRequestIsStale && - !draftEditorState.value.matchesSaveRequest( + !conversationDraftEditorDelegate.matchesSaveRequest( saveRequest = saveRequest, ) ) { @@ -288,11 +323,7 @@ internal class ConversationDraftDelegateImpl @Inject constructor( return@withLock } - updateDraftEditorState { currentDraftEditorState -> - currentDraftEditorState.withPersistedSaveResult( - saveRequest = saveRequest, - ) - } + conversationDraftEditorDelegate.applyPersistedSaveResult(saveRequest = saveRequest) } } @@ -303,18 +334,9 @@ internal class ConversationDraftDelegateImpl @Inject constructor( scope.launch(defaultDispatcher) { observeConversationDraftUpdates(conversationIdFlow = conversationIdFlow) .collect { persistedDraftUpdate -> - updateDraftEditorState { currentDraftEditorState -> - if (currentDraftEditorState.conversationId != - persistedDraftUpdate.conversationId - ) { - currentDraftEditorState - } else { - currentDraftEditorState.withPersistedDraft( - persistedDraft = persistedDraftUpdate.persistedDraft, - ) - } - } - applyPendingDraftSeedIfPossible() + conversationDraftEditorDelegate.applyPersistedDraftUpdate( + persistedDraftUpdate = persistedDraftUpdate, + ) } } } @@ -334,30 +356,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 - }, - ) - } + conversationDraftEditorDelegate.sendProtocolUpdates.collect { sendProtocol -> + conversationDraftEditorDelegate.applySendProtocol(sendProtocol = sendProtocol) } } } private suspend fun resetDraftEditorState(conversationId: String?) { - var previousDraftEditorState: DraftEditorState? = null + pendingMessageLimitSendRequest = null + _attachmentLimitWarning.value = null - updateDraftEditorState { currentDraftEditorState -> - previousDraftEditorState = currentDraftEditorState - DraftEditorState(conversationId = conversationId) - } - applyPendingDraftSeedIfPossible() + val previousSaveRequest = conversationDraftEditorDelegate.reset( + conversationId = conversationId, + ) - previousDraftEditorState - ?.toSaveRequestOrNull() + previousSaveRequest ?.let { saveRequest -> createSaveDraftOperationFlow( operationName = "flush previous draft", @@ -414,9 +427,9 @@ internal class ConversationDraftDelegateImpl @Inject constructor( private fun sendDraft(sendRequest: DraftSendRequest) { val scope = boundScope ?: return - if (markSendingForSendRequest(sendRequest = sendRequest)) { + if (conversationDraftEditorDelegate.markSendingForSendRequest(sendRequest = sendRequest)) { launchDraftOperation(scope = scope) { - createSendDraftFlow(sendRequest) + createSendDraftFlow(sendRequest = sendRequest) } } } @@ -433,46 +446,84 @@ internal class ConversationDraftDelegateImpl @Inject constructor( return runDraftOperationBoundary( operationName = "send draft", conversationId = sendRequest.conversationId, - onFailure = ::handleSendDraftFailure, + onFailure = { exception -> + handleSendDraftFailure( + exception = exception, + sendRequest = sendRequest, + ) + }, ) { sendConversationDraft( conversationId = sendRequest.conversationId, draft = sendRequest.draft, + ignoreMessageSizeLimit = sendRequest.ignoreMessageSizeLimit, ).onEach { - clearConversationDraftAfterSend(sendRequest = sendRequest) + conversationDraftEditorDelegate.clearConversationDraftAfterSend( + sendRequest = sendRequest, + ) didClearDraftAfterSend = true }.onCompletion { throwable -> if (throwable != null || !didClearDraftAfterSend) { - markConversationDraftAsIdle(conversationId = sendRequest.conversationId) + conversationDraftEditorDelegate.markConversationDraftAsIdle( + conversationId = sendRequest.conversationId, + ) } } } } - private fun handleSendDraftFailure(exception: Throwable) { + private fun handleSendDraftFailure( + exception: Throwable, + sendRequest: DraftSendRequest, + ) { // TODO: Add an extension that properly skip CancellationException manual handling - val messageResId = when (exception) { - is CancellationException -> return + val wasAttachmentLimitFailure = handleAttachmentLimitFailure( + exception = exception, + sendRequest = sendRequest, + ) - is ConversationSimNotReadyException -> { - R.string.cant_send_message_without_active_subscription - } + if (!wasAttachmentLimitFailure && exception !is CancellationException) { + emitEffect( + effect = ConversationScreenEffect.ShowMessage( + messageResId = resolveSendDraftFailureMessageResId(exception = exception), + ), + ) + } + } + private fun handleAttachmentLimitFailure( + exception: Throwable, + sendRequest: DraftSendRequest, + ): Boolean { + return when (exception) { is TooManyVideoAttachmentsException -> { - R.string.cant_send_message_with_multiple_videos + _attachmentLimitWarning.value = ConversationAttachmentLimitWarning + .SendingVideoAttachmentLimitReached + true + } + + is MessageLimitExceededException -> { + pendingMessageLimitSendRequest = sendRequest + _attachmentLimitWarning.value = ConversationAttachmentLimitWarning + .SendingMessageLimitReached + true + } + + else -> false + } + } + + private fun resolveSendDraftFailureMessageResId(exception: Throwable): Int { + return when (exception) { + is ConversationSimNotReadyException -> { + R.string.cant_send_message_without_active_subscription } 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( @@ -559,186 +610,14 @@ internal class ConversationDraftDelegateImpl @Inject constructor( operationName = "bind draft autosave", conversationId = null, ) { - draftEditorState - .map { currentDraftEditorState -> - currentDraftEditorState.toSaveRequestOrNull() - } + conversationDraftEditorDelegate + .saveRequests .distinctUntilChanged() .debounce(timeoutMillis = DRAFT_AUTOSAVE_DELAY_MILLIS) .filterNotNull() } } - 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() - } - - @Suppress("TooGenericExceptionCaught") - 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: Exception) { - 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) - val visibleState = updatedDraftEditorState.visibleState - val visibleSendProtocol = resolveVisibleSendProtocol( - previousState = _state.value, - visibleState = visibleState, - ) - - _state.value = visibleState.copy( - sendProtocol = visibleSendProtocol, - ) - - updatedDraftEditorState - } - } - - 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 - - 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) { - return@updateDraftEditorState currentDraftEditorState - } - - currentDraftEditorState.markIdle() - } - } - - private fun clearConversationDraftAfterSend(sendRequest: DraftSendRequest) { - updateDraftEditorState { latestDraftEditorState -> - if (latestDraftEditorState.conversationId != sendRequest.conversationId) { - return@updateDraftEditorState latestDraftEditorState - } - - latestDraftEditorState.clearDraftAfterSend( - sentDraft = sendRequest.draft, - ) - } - } - - private fun createSendRequestOrNull(): DraftSendRequest? { - val currentDraftEditorState = draftEditorState.value - val conversationId = currentDraftEditorState.conversationId - - return when { - !currentDraftEditorState.canSendDraft() -> null - conversationId == null -> null - - else -> { - DraftSendRequest( - conversationId = conversationId, - draft = currentDraftEditorState.effectiveDraft, - ) - } - } - } - - private fun markSendingForSendRequest(sendRequest: DraftSendRequest): Boolean { - var didMarkSending = false - - updateDraftEditorState { state -> - val isSameConversation = state.conversationId == sendRequest.conversationId - - val canMarkSending = isSameConversation && !state.isSending - - if (!canMarkSending) { - return@updateDraftEditorState state - } - - didMarkSending = true - state.markSending() - } - - return didMarkSending - } - private fun runDraftOperationBoundary( operationName: String, conversationId: String?, @@ -761,11 +640,5 @@ 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 } } - -private data class PendingDraftSeed( - val conversationId: String, - val draft: ConversationDraft, -) diff --git a/src/com/android/messaging/ui/conversation/composer/delegate/ConversationDraftEditorDelegate.kt b/src/com/android/messaging/ui/conversation/composer/delegate/ConversationDraftEditorDelegate.kt new file mode 100644 index 00000000..c6b6df93 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/composer/delegate/ConversationDraftEditorDelegate.kt @@ -0,0 +1,449 @@ +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.data.conversation.repository.ConversationSubscriptionsRepository +import com.android.messaging.domain.conversation.usecase.draft.ResolveConversationDraftSendProtocol +import com.android.messaging.domain.conversation.usecase.draft.ResolveDraftAttachmentsWithinLimit +import com.android.messaging.domain.conversation.usecase.draft.model.ConversationDraftSendProtocol +import com.android.messaging.domain.conversation.usecase.draft.model.DraftAttachmentLimitResult +import com.android.messaging.ui.conversation.composer.model.ConversationDraftState +import javax.inject.Inject +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.flow.update + +internal interface ConversationDraftEditorDelegate { + val state: StateFlow + val saveRequests: Flow + val sendProtocolUpdates: Flow + val currentSaveRequest: DraftSaveRequest? + + fun onMessageTextChanged(messageText: String) + + fun onSelfParticipantIdChanged(selfParticipantId: String) + + fun seedDraft( + conversationId: String, + draft: ConversationDraft, + ) + + fun addAttachments( + attachments: Collection, + ): DraftAttachmentLimitResult + + fun tryStartAddingAttachment(): Boolean + + fun addPendingAttachment(pendingAttachment: ConversationDraftPendingAttachment) + + fun removeAttachment(contentUri: String) + + fun removePendingAttachment(pendingAttachmentId: String) + + fun resolvePendingAttachment( + pendingAttachmentId: String, + attachment: ConversationDraftAttachment, + ): DraftPendingAttachmentResolution + + fun updateAttachmentCaption( + contentUri: String, + captionText: String, + ) + + fun reset(conversationId: String?): DraftSaveRequest? + + fun applyPersistedDraftUpdate(persistedDraftUpdate: PersistedDraftUpdate) + + fun matchesSaveRequest(saveRequest: DraftSaveRequest): Boolean + + fun applyPersistedSaveResult(saveRequest: DraftSaveRequest) + + fun applySendProtocol(sendProtocol: ConversationDraftSendProtocol) + + fun createSendRequestOrNull(): DraftSendRequest? + + fun markSendingForSendRequest(sendRequest: DraftSendRequest): Boolean + + fun markConversationDraftAsIdle(conversationId: String) + + fun clearConversationDraftAfterSend(sendRequest: DraftSendRequest) +} + +@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) +internal class ConversationDraftEditorDelegateImpl @Inject constructor( + private val conversationSubscriptionsRepository: ConversationSubscriptionsRepository, + private val resolveConversationDraftSendProtocol: ResolveConversationDraftSendProtocol, + private val resolveDraftAttachmentsWithinLimit: ResolveDraftAttachmentsWithinLimit, +) : ConversationDraftEditorDelegate { + + private val _state = MutableStateFlow(ConversationDraftState()) + private val draftEditorState = MutableStateFlow(DraftEditorState()) + + override val state: StateFlow = _state.asStateFlow() + override val saveRequests: Flow = draftEditorState + .map { currentDraftEditorState -> + currentDraftEditorState.toSaveRequestOrNull() + } + override val sendProtocolUpdates: Flow = draftEditorState + .map { currentDraftEditorState -> + DraftSendProtocolRequest( + conversationId = currentDraftEditorState.conversationId, + draft = currentDraftEditorState.effectiveDraft, + ) + } + .distinctUntilChanged() + .debounce(timeoutMillis = DRAFT_SEND_PROTOCOL_DEBOUNCE_MILLIS) + .mapLatest { request -> + resolveConversationDraftSendProtocol( + conversationId = request.conversationId, + draft = request.draft, + ) + } + .distinctUntilChanged() + override val currentSaveRequest: DraftSaveRequest? + get() { + return draftEditorState.value.toSaveRequestOrNull() + } + + private var pendingDraftSeed: PendingDraftSeed? = null + + override fun onMessageTextChanged(messageText: String) { + updateDraftEditorState { currentDraftEditorState -> + currentDraftEditorState.withMessageText(messageText) + } + } + + override fun onSelfParticipantIdChanged(selfParticipantId: String) { + updateDraftEditorState { currentDraftEditorState -> + currentDraftEditorState.withSelfParticipantId(selfParticipantId = selfParticipantId) + } + } + + override fun seedDraft( + conversationId: String, + draft: ConversationDraft, + ) { + pendingDraftSeed = PendingDraftSeed( + conversationId = conversationId, + draft = draft, + ) + applyPendingDraftSeedIfPossible() + } + + override fun addAttachments( + attachments: Collection, + ): DraftAttachmentLimitResult { + if (attachments.isEmpty()) { + return DraftAttachmentLimitResult( + attachmentsToAdd = emptyList(), + didDropAttachments = false, + ) + } + + val attachmentLimitResult = resolveDraftAttachmentsWithinLimit( + currentAttachments = draftEditorState.value.effectiveDraft.attachments, + attachmentsToAdd = attachments, + ) + val attachmentsToAdd = attachmentLimitResult.attachmentsToAdd + + if (attachmentsToAdd.isNotEmpty()) { + updateDraftEditorState { currentDraftEditorState -> + currentDraftEditorState.withAttachmentsAdded(attachments = attachmentsToAdd) + } + } + + return attachmentLimitResult + } + + override fun tryStartAddingAttachment(): Boolean { + val currentDraftEditorState = draftEditorState.value + val currentAttachmentCount = currentDraftEditorState.effectiveDraft.attachments.size + + currentDraftEditorState.pendingAttachments.size + + return currentAttachmentCount < conversationSubscriptionsRepository.resolveAttachmentLimit() + } + + override fun addPendingAttachment(pendingAttachment: ConversationDraftPendingAttachment) { + updateDraftEditorState { currentDraftEditorState -> + currentDraftEditorState.withPendingAttachmentAdded(pendingAttachment) + } + } + + override fun removeAttachment(contentUri: String) { + updateDraftEditorState { currentDraftEditorState -> + currentDraftEditorState.withAttachmentRemoved(contentUri = contentUri) + } + } + + override fun removePendingAttachment(pendingAttachmentId: String) { + updateDraftEditorState { currentDraftEditorState -> + currentDraftEditorState.withPendingAttachmentRemoved( + pendingAttachmentId = pendingAttachmentId, + ) + } + } + + override fun resolvePendingAttachment( + pendingAttachmentId: String, + attachment: ConversationDraftAttachment, + ): DraftPendingAttachmentResolution { + var resolution = DraftPendingAttachmentResolution( + didResolveAttachment = false, + didDropAttachments = false, + ) + + updateDraftEditorState { currentDraftEditorState -> + val resolutionState = resolvePendingAttachmentState( + currentDraftEditorState = currentDraftEditorState, + pendingAttachmentId = pendingAttachmentId, + attachment = attachment, + ) + + resolution = DraftPendingAttachmentResolution( + didResolveAttachment = resolutionState.didResolveAttachment, + didDropAttachments = resolutionState.didDropAttachments, + ) + + resolutionState.draftEditorState + } + + return resolution + } + + override fun updateAttachmentCaption( + contentUri: String, + captionText: String, + ) { + updateDraftEditorState { currentDraftEditorState -> + currentDraftEditorState.withAttachmentCaption( + contentUri = contentUri, + captionText = captionText, + ) + } + } + + override fun reset(conversationId: String?): DraftSaveRequest? { + val saveRequest = draftEditorState.value.toSaveRequestOrNull() + + updateDraftEditorState { + DraftEditorState(conversationId = conversationId) + } + applyPendingDraftSeedIfPossible() + + return saveRequest + } + + override fun applyPersistedDraftUpdate(persistedDraftUpdate: PersistedDraftUpdate) { + updateDraftEditorState { currentDraftEditorState -> + when { + currentDraftEditorState.conversationId != persistedDraftUpdate.conversationId -> { + currentDraftEditorState + } + + else -> { + currentDraftEditorState.withPersistedDraft( + persistedDraft = persistedDraftUpdate.persistedDraft, + ) + } + } + } + applyPendingDraftSeedIfPossible() + } + + override fun matchesSaveRequest(saveRequest: DraftSaveRequest): Boolean { + return draftEditorState.value.matchesSaveRequest(saveRequest = saveRequest) + } + + override fun applyPersistedSaveResult(saveRequest: DraftSaveRequest) { + updateDraftEditorState { currentDraftEditorState -> + currentDraftEditorState.withPersistedSaveResult(saveRequest = saveRequest) + } + } + + override fun applySendProtocol(sendProtocol: ConversationDraftSendProtocol) { + _state.update { currentState -> + currentState.copy( + sendProtocol = when { + currentState.draft.hasContent -> sendProtocol + else -> ConversationDraftSendProtocol.SMS + }, + ) + } + } + + override fun createSendRequestOrNull(): DraftSendRequest? { + val currentDraftEditorState = draftEditorState.value + val conversationId = currentDraftEditorState.conversationId + + return when { + conversationId == null -> null + !currentDraftEditorState.canSendDraft() -> null + + else -> { + DraftSendRequest( + conversationId = conversationId, + draft = currentDraftEditorState.effectiveDraft, + ) + } + } + } + + override fun markSendingForSendRequest(sendRequest: DraftSendRequest): Boolean { + var didMarkSending = false + + updateDraftEditorState { currentDraftEditorState -> + val isSameConversation = currentDraftEditorState.conversationId == + sendRequest.conversationId + val canMarkSending = isSameConversation && !currentDraftEditorState.isSending + + if (!canMarkSending) { + return@updateDraftEditorState currentDraftEditorState + } + + didMarkSending = true + currentDraftEditorState.markSending() + } + + return didMarkSending + } + + override fun markConversationDraftAsIdle(conversationId: String) { + updateDraftEditorState { currentDraftEditorState -> + if (currentDraftEditorState.conversationId != conversationId) { + return@updateDraftEditorState currentDraftEditorState + } + + currentDraftEditorState.markIdle() + } + } + + override fun clearConversationDraftAfterSend(sendRequest: DraftSendRequest) { + updateDraftEditorState { currentDraftEditorState -> + if (currentDraftEditorState.conversationId != sendRequest.conversationId) { + return@updateDraftEditorState currentDraftEditorState + } + + currentDraftEditorState.clearDraftAfterSend(sentDraft = sendRequest.draft) + } + } + + private fun resolvePendingAttachmentState( + currentDraftEditorState: DraftEditorState, + pendingAttachmentId: String, + attachment: ConversationDraftAttachment, + ): PendingAttachmentResolutionState { + val hasPendingAttachment = currentDraftEditorState + .pendingAttachments + .any { pendingAttachment -> + pendingAttachment.pendingAttachmentId == pendingAttachmentId + } + + if (!hasPendingAttachment) { + return PendingAttachmentResolutionState( + draftEditorState = currentDraftEditorState, + didResolveAttachment = false, + didDropAttachments = false, + ) + } + + val draftEditorStateWithoutPendingAttachment = currentDraftEditorState + .withPendingAttachmentRemoved(pendingAttachmentId = pendingAttachmentId) + + val attachmentLimitResult = resolveDraftAttachmentsWithinLimit( + currentAttachments = draftEditorStateWithoutPendingAttachment + .effectiveDraft + .attachments, + attachmentsToAdd = listOf(attachment), + ) + val attachmentsToAdd = attachmentLimitResult.attachmentsToAdd + val updatedDraftEditorState = draftEditorStateWithoutPendingAttachment + .withAttachmentsAdded(attachments = attachmentsToAdd) + + return PendingAttachmentResolutionState( + draftEditorState = updatedDraftEditorState, + didResolveAttachment = attachmentsToAdd.any { acceptedAttachment -> + acceptedAttachment.contentUri == attachment.contentUri + }, + didDropAttachments = attachmentLimitResult.didDropAttachments, + ) + } + + private fun updateDraftEditorState(transform: (DraftEditorState) -> DraftEditorState) { + draftEditorState.update { currentDraftEditorState -> + val updatedDraftEditorState = transform(currentDraftEditorState) + val visibleState = updatedDraftEditorState.visibleState + val visibleSendProtocol = resolveVisibleSendProtocol( + previousState = _state.value, + visibleState = visibleState, + ) + + _state.value = visibleState.copy( + sendProtocol = visibleSendProtocol, + ) + + updatedDraftEditorState + } + } + + 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 + + updateDraftEditorState { currentDraftEditorState -> + if (currentDraftEditorState.conversationId != pendingDraftSeed.conversationId) { + return@updateDraftEditorState currentDraftEditorState + } + + this.pendingDraftSeed = null + currentDraftEditorState.withSeededDraft(draft = pendingDraftSeed.draft) + } + } + + private companion object { + private const val DRAFT_SEND_PROTOCOL_DEBOUNCE_MILLIS = 250L + } +} + +private data class DraftSendProtocolRequest( + val conversationId: String?, + val draft: ConversationDraft, +) + +internal data class DraftPendingAttachmentResolution( + val didResolveAttachment: Boolean, + val didDropAttachments: Boolean, +) + +private data class PendingDraftSeed( + val conversationId: String, + val draft: ConversationDraft, +) + +private data class PendingAttachmentResolutionState( + val draftEditorState: DraftEditorState, + val didResolveAttachment: Boolean, + val didDropAttachments: Boolean, +) 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 2e45de71..4ceb4fbf 100644 --- a/src/com/android/messaging/ui/conversation/composer/delegate/ConversationDraftEditorState.kt +++ b/src/com/android/messaging/ui/conversation/composer/delegate/ConversationDraftEditorState.kt @@ -214,15 +214,6 @@ internal data class DraftEditorState( 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 && @@ -401,6 +392,7 @@ internal data class DraftSaveRequest( internal data class DraftSendRequest( val conversationId: String, val draft: ConversationDraft, + val ignoreMessageSizeLimit: Boolean = false, ) internal data class PersistedDraftUpdate( diff --git a/src/com/android/messaging/ui/conversation/mediapicker/ConversationMediaPicker.kt b/src/com/android/messaging/ui/conversation/mediapicker/ConversationMediaPicker.kt index e5b3e198..77f0e247 100644 --- a/src/com/android/messaging/ui/conversation/mediapicker/ConversationMediaPicker.kt +++ b/src/com/android/messaging/ui/conversation/mediapicker/ConversationMediaPicker.kt @@ -61,6 +61,7 @@ internal fun ConversationMediaPicker( photoPickerSourceContentUriByAttachmentContentUri: ImmutableMap, onPhotoPickerMediaSelected: (List) -> Unit, onPhotoPickerMediaDeselected: (List) -> Unit, + onAttachmentStartRequest: () -> Boolean, onRequestAudioPermission: () -> Unit, onRequestCameraPermission: () -> Unit, onCapturedMediaReady: (ConversationCapturedMedia) -> Unit, @@ -96,6 +97,7 @@ internal fun ConversationMediaPicker( photoPickerSourceContentUriByAttachmentContentUri, onPhotoPickerMediaSelected = onPhotoPickerMediaSelected, onPhotoPickerMediaDeselected = onPhotoPickerMediaDeselected, + onAttachmentStartRequest = onAttachmentStartRequest, onRequestAudioPermission = onRequestAudioPermission, onRequestCameraPermission = onRequestCameraPermission, onCapturedMediaReady = onCapturedMediaReady, @@ -138,6 +140,7 @@ private fun rememberConversationEmbeddedPhotoPickerState( sheetState: SheetState, state: ConversationMediaPickerState, coroutineScope: CoroutineScope, + onAttachmentStartRequest: () -> Boolean, onPhotoPickerMediaSelected: (List) -> Unit, onPhotoPickerMediaDeselected: (List) -> Unit, ): EmbeddedPhotoPickerState { @@ -148,8 +151,11 @@ private fun rememberConversationEmbeddedPhotoPickerState( }, onUriPermissionGranted = { uris -> val contentUris = uris.map(Uri::toString) - onPhotoPickerMediaSelected(contentUris) - contentUris.lastOrNull()?.let(state::showReview) + + if (contentUris.isNotEmpty() && onAttachmentStartRequest()) { + onPhotoPickerMediaSelected(contentUris) + contentUris.lastOrNull()?.let(state::showReview) + } }, onUriPermissionRevoked = { uris -> onPhotoPickerMediaDeselected(uris.map(Uri::toString)) @@ -228,6 +234,7 @@ private fun ConversationMediaPickerContent( photoPickerSourceContentUriByAttachmentContentUri: ImmutableMap, onPhotoPickerMediaSelected: (List) -> Unit, onPhotoPickerMediaDeselected: (List) -> Unit, + onAttachmentStartRequest: () -> Boolean, onRequestAudioPermission: () -> Unit, onRequestCameraPermission: () -> Unit, onCapturedMediaReady: (ConversationCapturedMedia) -> Unit, @@ -245,6 +252,7 @@ private fun ConversationMediaPickerContent( sheetState = sheetState, state = state, coroutineScope = coroutineScope, + onAttachmentStartRequest = onAttachmentStartRequest, onPhotoPickerMediaSelected = onPhotoPickerMediaSelected, onPhotoPickerMediaDeselected = onPhotoPickerMediaDeselected, ) @@ -280,6 +288,7 @@ private fun ConversationMediaPickerContent( photoPickerSourceContentUriByAttachmentContentUri, onRequestAudioPermission = onRequestAudioPermission, onRequestCameraPermission = onRequestCameraPermission, + onAttachmentStartRequest = onAttachmentStartRequest, onCapturedMediaReady = onCapturedMediaReady, onSendClick = onSendClick, ) @@ -308,6 +317,7 @@ private fun ConversationMediaPickerScaffoldContent( photoPickerSourceContentUriByAttachmentContentUri: ImmutableMap, onRequestAudioPermission: () -> Unit, onRequestCameraPermission: () -> Unit, + onAttachmentStartRequest: () -> Boolean, onCapturedMediaReady: (ConversationCapturedMedia) -> Unit, onSendClick: () -> Unit, ) { @@ -338,6 +348,7 @@ private fun ConversationMediaPickerScaffoldContent( photoPickerSourceContentUriByAttachmentContentUri, onRequestAudioPermission = onRequestAudioPermission, onRequestCameraPermission = onRequestCameraPermission, + onAttachmentStartRequest = onAttachmentStartRequest, onCapturedMediaReady = onCapturedMediaReady, onSendClick = onSendClick, onShowReview = state::showReview, diff --git a/src/com/android/messaging/ui/conversation/mediapicker/ConversationMediaPickerCaptureRoute.kt b/src/com/android/messaging/ui/conversation/mediapicker/ConversationMediaPickerCaptureRoute.kt index 3b7bb745..fd2dbb5b 100644 --- a/src/com/android/messaging/ui/conversation/mediapicker/ConversationMediaPickerCaptureRoute.kt +++ b/src/com/android/messaging/ui/conversation/mediapicker/ConversationMediaPickerCaptureRoute.kt @@ -19,6 +19,7 @@ internal fun ConversationMediaCaptureRoute( captureMode: ConversationCaptureMode, onClose: () -> Unit, onRequestAudioPermission: () -> Unit, + onAttachmentStartRequest: () -> Boolean, onShowReview: (String) -> Unit, onCapturedMediaReady: (ConversationCapturedMedia) -> Unit, onCaptureModeChange: (ConversationCaptureMode) -> Unit, @@ -46,38 +47,32 @@ internal fun ConversationMediaCaptureRoute( } onClose() }, - onRequestAudioPermission = onRequestAudioPermission, + onRequestAudioPermission = { + if (onAttachmentStartRequest()) { + onRequestAudioPermission() + } + }, onPhotoCaptureClick = { handlePhotoCaptureRequest( cameraController = cameraController, + onAttachmentStartRequest = onAttachmentStartRequest, onCapturedMediaReady = onCapturedMediaReady, onShowReview = onShowReview, ) }, - onPhotoModeClick = { - onCaptureModeChange(ConversationCaptureMode.Photo) - }, - onSwitchCameraClick = { - handleSwitchCameraRequest( - cameraController = cameraController, - ) - }, - onToggleFlashClick = { - handleToggleFlashRequest( - cameraController = cameraController, - ) - }, + onPhotoModeClick = { onCaptureModeChange(ConversationCaptureMode.Photo) }, + onSwitchCameraClick = { handleSwitchCameraRequest(cameraController) }, + onToggleFlashClick = { handleToggleFlashRequest(cameraController) }, onVideoCaptureClick = { handleVideoCaptureRequest( cameraController = cameraController, isRecording = isRecording.value, + onAttachmentStartRequest = onAttachmentStartRequest, onCapturedMediaReady = onCapturedMediaReady, onShowReview = onShowReview, ) }, - onVideoModeClick = { - onCaptureModeChange(ConversationCaptureMode.Video) - }, + onVideoModeClick = { onCaptureModeChange(ConversationCaptureMode.Video) }, recordingDurationMillis = recordingDurationMillis.value, ) } diff --git a/src/com/android/messaging/ui/conversation/mediapicker/ConversationMediaPickerCaptureScene.kt b/src/com/android/messaging/ui/conversation/mediapicker/ConversationMediaPickerCaptureScene.kt index f9076a8f..bd58d797 100644 --- a/src/com/android/messaging/ui/conversation/mediapicker/ConversationMediaPickerCaptureScene.kt +++ b/src/com/android/messaging/ui/conversation/mediapicker/ConversationMediaPickerCaptureScene.kt @@ -22,6 +22,7 @@ internal fun ConversationMediaPickerCaptureScene( onClose: () -> Unit, onRequestAudioPermission: () -> Unit, onRequestCameraPermission: () -> Unit, + onAttachmentStartRequest: () -> Boolean, onCapturedMediaReady: (ConversationCapturedMedia) -> Unit, onShowReview: (String) -> Unit, onCaptureModeChange: (ConversationCaptureMode) -> Unit, @@ -47,6 +48,7 @@ internal fun ConversationMediaPickerCaptureScene( captureMode = captureMode, onClose = onClose, onRequestAudioPermission = onRequestAudioPermission, + onAttachmentStartRequest = onAttachmentStartRequest, onShowReview = onShowReview, onCapturedMediaReady = onCapturedMediaReady, onCaptureModeChange = onCaptureModeChange, diff --git a/src/com/android/messaging/ui/conversation/mediapicker/ConversationMediaPickerOverlay.kt b/src/com/android/messaging/ui/conversation/mediapicker/ConversationMediaPickerOverlay.kt index 37bce7b3..b6dfd702 100644 --- a/src/com/android/messaging/ui/conversation/mediapicker/ConversationMediaPickerOverlay.kt +++ b/src/com/android/messaging/ui/conversation/mediapicker/ConversationMediaPickerOverlay.kt @@ -35,6 +35,7 @@ internal fun ConversationMediaPickerOverlay( photoPickerSourceContentUriByAttachmentContentUri: ImmutableMap, onPhotoPickerMediaSelected: (List) -> Unit, onPhotoPickerMediaDeselected: (List) -> Unit, + onAttachmentStartRequest: () -> Boolean, onCapturedMediaReady: (ConversationCapturedMedia) -> Unit, onSendClick: () -> Unit, ) { @@ -91,6 +92,7 @@ internal fun ConversationMediaPickerOverlay( photoPickerSourceContentUriByAttachmentContentUri, onPhotoPickerMediaSelected = onPhotoPickerMediaSelected, onPhotoPickerMediaDeselected = onPhotoPickerMediaDeselected, + onAttachmentStartRequest = onAttachmentStartRequest, onRequestAudioPermission = { audioPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO) }, diff --git a/src/com/android/messaging/ui/conversation/mediapicker/ConversationMediaPickerScaffold.kt b/src/com/android/messaging/ui/conversation/mediapicker/ConversationMediaPickerScaffold.kt index 2feb031d..f5310eea 100644 --- a/src/com/android/messaging/ui/conversation/mediapicker/ConversationMediaPickerScaffold.kt +++ b/src/com/android/messaging/ui/conversation/mediapicker/ConversationMediaPickerScaffold.kt @@ -51,6 +51,7 @@ internal fun ConversationMediaPickerScaffold( photoPickerSourceContentUriByAttachmentContentUri: ImmutableMap, onRequestAudioPermission: () -> Unit, onRequestCameraPermission: () -> Unit, + onAttachmentStartRequest: () -> Boolean, onCapturedMediaReady: (ConversationCapturedMedia) -> Unit, onSendClick: () -> Unit, onShowReview: (String) -> Unit, @@ -83,6 +84,7 @@ internal fun ConversationMediaPickerScaffold( photoPickerSourceContentUriByAttachmentContentUri, onRequestAudioPermission = onRequestAudioPermission, onRequestCameraPermission = onRequestCameraPermission, + onAttachmentStartRequest = onAttachmentStartRequest, onCapturedMediaReady = onCapturedMediaReady, onSendClick = onSendClick, onShowReview = onShowReview, @@ -114,6 +116,7 @@ private fun ConversationMediaPickerOverlayHost( photoPickerSourceContentUriByAttachmentContentUri: ImmutableMap, onRequestAudioPermission: () -> Unit, onRequestCameraPermission: () -> Unit, + onAttachmentStartRequest: () -> Boolean, onCapturedMediaReady: (ConversationCapturedMedia) -> Unit, onSendClick: () -> Unit, onShowReview: (String) -> Unit, @@ -140,6 +143,7 @@ private fun ConversationMediaPickerOverlayHost( onClose = onClose, onRequestAudioPermission = onRequestAudioPermission, onRequestCameraPermission = onRequestCameraPermission, + onAttachmentStartRequest = onAttachmentStartRequest, onCapturedMediaReady = onCapturedMediaReady, onShowReview = onShowReview, onCaptureModeChange = onCaptureModeChange, diff --git a/src/com/android/messaging/ui/conversation/mediapicker/camera/ConversationMediaPickerActions.kt b/src/com/android/messaging/ui/conversation/mediapicker/camera/ConversationMediaPickerActions.kt index 68998b72..48cb0dcf 100644 --- a/src/com/android/messaging/ui/conversation/mediapicker/camera/ConversationMediaPickerActions.kt +++ b/src/com/android/messaging/ui/conversation/mediapicker/camera/ConversationMediaPickerActions.kt @@ -6,9 +6,14 @@ import com.android.messaging.util.UiUtils internal fun handlePhotoCaptureRequest( cameraController: ConversationCameraController, + onAttachmentStartRequest: () -> Boolean, onCapturedMediaReady: (ConversationCapturedMedia) -> Unit, onShowReview: (String) -> Unit, ) { + if (!onAttachmentStartRequest()) { + return + } + cameraController.capturePhoto( onCaptured = { capturedMedia -> onCapturedMediaReady(capturedMedia) @@ -45,6 +50,7 @@ internal fun handleToggleFlashRequest(cameraController: ConversationCameraContro internal fun handleVideoCaptureRequest( cameraController: ConversationCameraController, isRecording: Boolean, + onAttachmentStartRequest: () -> Boolean, onCapturedMediaReady: (ConversationCapturedMedia) -> Unit, onShowReview: (String) -> Unit, ) { @@ -53,6 +59,10 @@ internal fun handleVideoCaptureRequest( return } + if (!onAttachmentStartRequest()) { + return + } + cameraController.startVideoRecording( withAudio = true, onCaptured = { capturedMedia -> diff --git a/src/com/android/messaging/ui/conversation/mediapicker/delegate/ConversationMediaPickerDelegate.kt b/src/com/android/messaging/ui/conversation/mediapicker/delegate/ConversationMediaPickerDelegate.kt index 8a87430c..0d48e3f5 100644 --- a/src/com/android/messaging/ui/conversation/mediapicker/delegate/ConversationMediaPickerDelegate.kt +++ b/src/com/android/messaging/ui/conversation/mediapicker/delegate/ConversationMediaPickerDelegate.kt @@ -1,6 +1,7 @@ package com.android.messaging.ui.conversation.mediapicker.delegate import com.android.messaging.R +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.ConversationCapturedMedia import com.android.messaging.data.media.model.PhotoPickerDraftAttachmentResult @@ -101,9 +102,18 @@ internal class ConversationMediaPickerDelegateImpl @Inject constructor( } override fun onPhotoPickerMediaSelected(contentUris: List) { - claimNewPhotoPickerContentUris(contentUris = contentUris) - .takeIf { it.isNotEmpty() } - ?.let(::launchPhotoPickerAttachmentResolution) + val claimedContentUris = claimNewPhotoPickerContentUris(contentUris = contentUris) + + if (claimedContentUris.isEmpty()) { + return + } + + if (!conversationDraftDelegate.tryStartAddingAttachment()) { + releasePhotoPickerContentUris(contentUris = claimedContentUris) + return + } + + launchPhotoPickerAttachmentResolution(contentUris = claimedContentUris) } private fun claimNewPhotoPickerContentUris(contentUris: List): List { @@ -165,29 +175,73 @@ internal class ConversationMediaPickerDelegateImpl @Inject constructor( private fun onPhotoPickerAttachmentResolved( photoPickerAttachment: PhotoPickerDraftAttachment, ) { - val shouldDeleteTemporaryAttachment = synchronized(photoPickerAttachmentLock) { - val sourceContentUri = photoPickerAttachment.sourceContentUri - if (!photoPickerContentUris.contains(sourceContentUri)) { - return@synchronized true - } + val sourceContentUri = photoPickerAttachment.sourceContentUri + val draftAttachment = photoPickerAttachment.draftAttachment - registerPhotoPickerAttachment(photoPickerAttachment) - conversationDraftDelegate.addAttachments( - attachments = listOf( - photoPickerAttachment.draftAttachment, - ), + if (!isPhotoPickerContentUriSelected(contentUri = sourceContentUri)) { + deleteTemporaryAttachment(contentUri = draftAttachment.contentUri) + return + } + + val wasAddedToDraft = addDraftAttachmentIfAccepted(draftAttachment = draftAttachment) + val wasRegistered = wasAddedToDraft && registerPhotoPickerAttachmentIfStillSelected( + photoPickerAttachment = photoPickerAttachment, + ) + + if (!wasRegistered) { + discardUnregisteredPhotoPickerAttachment( + sourceContentUri = sourceContentUri, + attachmentContentUri = draftAttachment.contentUri, + wasAddedToDraft = wasAddedToDraft, ) + } + } - false + private fun isPhotoPickerContentUriSelected(contentUri: String): Boolean { + return synchronized(photoPickerAttachmentLock) { + photoPickerContentUris.contains(contentUri) } + } - if (shouldDeleteTemporaryAttachment) { - deleteTemporaryAttachment( - contentUri = photoPickerAttachment.draftAttachment.contentUri, - ) + private fun addDraftAttachmentIfAccepted( + draftAttachment: ConversationDraftAttachment, + ): Boolean { + val acceptedAttachments = conversationDraftDelegate.addAttachments( + attachments = listOf(draftAttachment), + ) + + return acceptedAttachments.any { acceptedAttachment -> + acceptedAttachment.contentUri == draftAttachment.contentUri } } + private fun registerPhotoPickerAttachmentIfStillSelected( + photoPickerAttachment: PhotoPickerDraftAttachment, + ): Boolean { + return synchronized(photoPickerAttachmentLock) { + if (!photoPickerContentUris.contains(photoPickerAttachment.sourceContentUri)) { + return@synchronized false + } + + registerPhotoPickerAttachment(photoPickerAttachment) + true + } + } + + private fun discardUnregisteredPhotoPickerAttachment( + sourceContentUri: String, + attachmentContentUri: String, + wasAddedToDraft: Boolean, + ) { + releasePhotoPickerContentUri(sourceContentUri) + + if (wasAddedToDraft) { + conversationDraftDelegate.removeAttachment(attachmentContentUri) + } + + deleteTemporaryAttachment(attachmentContentUri) + } + private fun releasePhotoPickerContentUri(contentUri: String): Boolean { return synchronized(photoPickerAttachmentLock) { photoPickerContentUris.remove(contentUri) @@ -228,13 +282,19 @@ internal class ConversationMediaPickerDelegateImpl @Inject constructor( } override fun onCapturedMediaReady(capturedMedia: ConversationCapturedMedia) { - conversationDraftDelegate.addAttachments( - attachments = listOf( - conversationDraftAttachmentMapper.map( - capturedMedia = capturedMedia, - ), - ), + val attachment = conversationDraftAttachmentMapper.map(capturedMedia) + + val acceptedAttachments = conversationDraftDelegate.addAttachments( + attachments = listOf(attachment), ) + + val wasAccepted = acceptedAttachments.any { acceptedAttachment -> + acceptedAttachment.contentUri == attachment.contentUri + } + + if (!wasAccepted) { + deleteTemporaryAttachment(attachment.contentUri) + } } override fun onContactCardPicked(contactUri: String?) { diff --git a/src/com/android/messaging/ui/conversation/screen/ConversationScreen.kt b/src/com/android/messaging/ui/conversation/screen/ConversationScreen.kt index 5c3ffb27..4452bc1e 100644 --- a/src/com/android/messaging/ui/conversation/screen/ConversationScreen.kt +++ b/src/com/android/messaging/ui/conversation/screen/ConversationScreen.kt @@ -5,15 +5,12 @@ 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.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 import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -27,7 +24,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.geometry.Rect as ComposeRect 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.compose.collectAsStateWithLifecycle @@ -44,7 +40,6 @@ import com.android.messaging.ui.conversation.messages.model.message.Conversation 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 import kotlinx.collections.immutable.ImmutableList @@ -290,27 +285,6 @@ private fun ConversationScreenBottomBar( ) } -@Composable -private fun ConversationScreenDialogs( - uiState: ConversationScreenScaffoldUiState, - screenModel: ConversationScreenModel, -) { - uiState.selection.deleteConfirmation?.let { deleteConfirmation -> - ConversationDeleteMessagesDialog( - deleteConfirmation = deleteConfirmation, - onConfirm = screenModel::confirmDeleteSelectedMessages, - onDismiss = screenModel::dismissDeleteMessageConfirmation, - ) - } - - if (uiState.isDeleteConversationConfirmationVisible) { - ConversationDeleteConversationDialog( - onConfirm = screenModel::confirmDeleteConversation, - onDismiss = screenModel::dismissDeleteConversationConfirmation, - ) - } -} - @Composable private fun ConversationScreenSimSelectorSheet( isVisible: Boolean, @@ -332,35 +306,6 @@ private fun ConversationScreenSimSelectorSheet( ) } -@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 private fun ConversationScreenContent( modifier: Modifier = Modifier, @@ -439,49 +384,6 @@ private fun shouldShowIncomingSenderLabels(metadata: ConversationMetadataUiState } } -@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/screen/ConversationScreenDialogs.kt b/src/com/android/messaging/ui/conversation/screen/ConversationScreenDialogs.kt new file mode 100644 index 00000000..d52d8352 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/screen/ConversationScreenDialogs.kt @@ -0,0 +1,170 @@ +package com.android.messaging.ui.conversation.screen + +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import com.android.messaging.R +import com.android.messaging.ui.conversation.screen.model.ConversationAttachmentLimitWarning +import com.android.messaging.ui.conversation.screen.model.ConversationMessageDeleteConfirmationUiState +import com.android.messaging.ui.conversation.screen.model.ConversationScreenScaffoldUiState + +@Composable +internal fun ConversationScreenDialogs( + uiState: ConversationScreenScaffoldUiState, + screenModel: ConversationScreenModel, +) { + uiState.attachmentLimitWarning?.let { warning -> + ConversationAttachmentLimitWarningDialog( + warning = warning, + onDismiss = screenModel::dismissAttachmentLimitWarning, + onSendAnyway = screenModel::sendAnywayAfterAttachmentLimitWarning, + ) + } + + uiState.selection.deleteConfirmation?.let { deleteConfirmation -> + ConversationDeleteMessagesDialog( + deleteConfirmation = deleteConfirmation, + onConfirm = screenModel::confirmDeleteSelectedMessages, + onDismiss = screenModel::dismissDeleteMessageConfirmation, + ) + } + + if (uiState.isDeleteConversationConfirmationVisible) { + ConversationDeleteConversationDialog( + onConfirm = screenModel::confirmDeleteConversation, + onDismiss = screenModel::dismissDeleteConversationConfirmation, + ) + } +} + +@Composable +private fun ConversationAttachmentLimitWarningDialog( + warning: ConversationAttachmentLimitWarning, + onDismiss: () -> Unit, + onSendAnyway: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { + Text( + text = stringResource(R.string.mms_attachment_limit_reached), + ) + }, + text = { + Text( + text = stringResource( + id = when (warning) { + ConversationAttachmentLimitWarning.ComposingAttachmentLimitReached -> { + R.string.attachment_limit_reached_dialog_message_when_composing + } + + ConversationAttachmentLimitWarning.SendingMessageLimitReached -> { + R.string.attachment_limit_reached_dialog_message_when_sending + } + + ConversationAttachmentLimitWarning.SendingVideoAttachmentLimitReached -> { + R.string.video_attachment_limit_exceeded_when_sending + } + }, + ), + ) + }, + confirmButton = { + TextButton(onClick = onDismiss) { + Text( + text = stringResource(android.R.string.ok), + ) + } + }, + dismissButton = when (warning) { + ConversationAttachmentLimitWarning.SendingMessageLimitReached -> { + { + TextButton(onClick = onSendAnyway) { + Text( + text = stringResource(R.string.attachment_limit_reached_send_anyway), + ) + } + } + } + + ConversationAttachmentLimitWarning.ComposingAttachmentLimitReached, + ConversationAttachmentLimitWarning.SendingVideoAttachmentLimitReached, + -> null + }, + ) +} + +@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 +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), + ) + } + }, + ) +} diff --git a/src/com/android/messaging/ui/conversation/screen/ConversationScreenRoute.kt b/src/com/android/messaging/ui/conversation/screen/ConversationScreenRoute.kt index 0a4e57f8..1fb2cfb8 100644 --- a/src/com/android/messaging/ui/conversation/screen/ConversationScreenRoute.kt +++ b/src/com/android/messaging/ui/conversation/screen/ConversationScreenRoute.kt @@ -42,9 +42,11 @@ internal fun rememberOpenContactPickerCallback( screenModel.onContactCardPicked(contactUri = contactUri?.toString()) } - return remember(contactPickerLauncher) { + return remember(screenModel, contactPickerLauncher) { { - contactPickerLauncher.launch(input = null) + if (screenModel.tryStartAddingAttachment()) { + contactPickerLauncher.launch(input = null) + } } } } @@ -66,24 +68,34 @@ internal fun rememberAudioRecordingStartRequest( val startMode = pendingAudioRecordingStartMode pendingAudioRecordingStartMode = PendingAudioRecordingStartMode.None - if (isGranted) { - startAudioRecording( - screenModel = screenModel, - startMode = startMode, - ) + when { + 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) + val canStartAddingAttachment = screenModel.tryStartAddingAttachment() + + when { + !canStartAddingAttachment -> Unit + + permissionState.audioPermissionGranted -> { + startAudioRecording( + screenModel = screenModel, + startMode = startMode, + ) + } + + else -> { + pendingAudioRecordingStartMode = startMode + audioPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO) + } } } } @@ -286,6 +298,7 @@ private fun ConversationMediaPickerOverlayHost( uiState.photoPickerSourceContentUriByAttachmentContentUri, onPhotoPickerMediaSelected = screenModel::onPhotoPickerMediaSelected, onPhotoPickerMediaDeselected = screenModel::onPhotoPickerMediaDeselected, + onAttachmentStartRequest = screenModel::tryStartAddingAttachment, onCapturedMediaReady = screenModel::onCapturedMediaReady, onSendClick = screenModel::onSendClick, ) diff --git a/src/com/android/messaging/ui/conversation/screen/ConversationViewModel.kt b/src/com/android/messaging/ui/conversation/screen/ConversationViewModel.kt index a89c0dd8..0208f4b9 100644 --- a/src/com/android/messaging/ui/conversation/screen/ConversationViewModel.kt +++ b/src/com/android/messaging/ui/conversation/screen/ConversationViewModel.kt @@ -28,6 +28,7 @@ import com.android.messaging.ui.conversation.messages.delegate.ConversationMessa 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.ConversationAttachmentLimitWarning 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 @@ -86,6 +87,7 @@ internal interface ConversationScreenModel { fun onPhotoPickerMediaDeselected(contentUris: List) fun onContactCardPicked(contactUri: String?) fun onMessageTextChanged(text: String) + fun tryStartAddingAttachment(): Boolean fun onAudioRecordingStart() fun onLockedAudioRecordingStart() fun onAudioRecordingLock(): Boolean @@ -103,6 +105,8 @@ internal interface ConversationScreenModel { fun dismissMessageSelection() fun confirmDeleteSelectedMessages() fun onSendClick() + fun dismissAttachmentLimitWarning() + fun sendAnywayAfterAttachmentLimitWarning() fun onDefaultSmsRolePromptActionClick() fun onDefaultSmsRoleRequestResult(resultCode: Int) fun onDefaultSmsRoleRequestLaunchFailed() @@ -193,19 +197,31 @@ internal class ConversationViewModel @Inject constructor( ), ) + private val dialogUiState = combine( + conversationDraftDelegate.attachmentLimitWarning, + conversationMetadataDelegate.isDeleteConversationConfirmationVisible, + ) { attachmentLimitWarning, isDeleteConversationConfirmationVisible -> + ConversationScreenDialogUiState( + attachmentLimitWarning = attachmentLimitWarning, + isDeleteConversationConfirmationVisible = isDeleteConversationConfirmationVisible, + ) + } + override val scaffoldUiState: StateFlow = combine( conversationMetadataDelegate.state, conversationMessagesDelegate.state, composerUiState, conversationMessageSelectionDelegate.state, - conversationMetadataDelegate.isDeleteConversationConfirmationVisible, - ) { metadataState, messagesUiState, composerUiState, selectionUiState, isDeleteConfirmVisible -> + dialogUiState, + ) { metadataState, messagesUiState, composerUiState, selectionUiState, dialogUiState -> buildScaffoldUiState( metadataState = metadataState, messagesUiState = messagesUiState, composerUiState = composerUiState, selectionUiState = selectionUiState, - isDeleteConversationConfirmationVisible = isDeleteConfirmVisible, + attachmentLimitWarning = dialogUiState.attachmentLimitWarning, + isDeleteConversationConfirmationVisible = dialogUiState + .isDeleteConversationConfirmationVisible, ) }.stateIn( scope = viewModelScope, @@ -217,6 +233,7 @@ internal class ConversationViewModel @Inject constructor( messagesUiState = conversationMessagesDelegate.state.value, composerUiState = composerUiState.value, selectionUiState = conversationMessageSelectionDelegate.state.value, + attachmentLimitWarning = conversationDraftDelegate.attachmentLimitWarning.value, isDeleteConversationConfirmationVisible = conversationMetadataDelegate.isDeleteConversationConfirmationVisible.value, ), @@ -227,6 +244,7 @@ internal class ConversationViewModel @Inject constructor( messagesUiState: ConversationMessagesUiState, composerUiState: ConversationComposerUiState, selectionUiState: ConversationMessageSelectionUiState, + attachmentLimitWarning: ConversationAttachmentLimitWarning?, isDeleteConversationConfirmationVisible: Boolean, ): ConversationScreenScaffoldUiState { val isPresent = metadataState is ConversationMetadataUiState.Present @@ -239,6 +257,7 @@ internal class ConversationViewModel @Inject constructor( canUnarchive = isPresent && presentMetadata?.isArchived == true, canAddContact = canAddContact(metadataState = metadataState), canDeleteConversation = isPresent, + attachmentLimitWarning = attachmentLimitWarning, isDeleteConversationConfirmationVisible = isDeleteConversationConfirmationVisible, metadata = metadataState, messages = messagesUiState, @@ -507,6 +526,10 @@ internal class ConversationViewModel @Inject constructor( conversationDraftDelegate.onMessageTextChanged(messageText = text) } + override fun tryStartAddingAttachment(): Boolean { + return conversationDraftDelegate.tryStartAddingAttachment() + } + override fun onAudioRecordingStart() { startAudioRecording(isLocked = false) } @@ -516,6 +539,10 @@ internal class ConversationViewModel @Inject constructor( } private fun startAudioRecording(isLocked: Boolean) { + if (!conversationDraftDelegate.tryStartAddingAttachment()) { + return + } + val effectiveSelfParticipantId = composerUiState.value .simSelector .selectedSubscription @@ -587,6 +614,14 @@ internal class ConversationViewModel @Inject constructor( conversationDraftDelegate.onSendClick() } + override fun dismissAttachmentLimitWarning() { + conversationDraftDelegate.dismissAttachmentLimitWarning() + } + + override fun sendAnywayAfterAttachmentLimitWarning() { + conversationDraftDelegate.sendAnywayAfterAttachmentLimitWarning() + } + override fun onDefaultSmsRolePromptActionClick() { viewModelScope.launch(defaultDispatcher) { when (val requestIntent = createDefaultSmsRoleRequest()) { @@ -704,3 +739,8 @@ internal class ConversationViewModel @Inject constructor( private const val STATEFLOW_STOP_TIMEOUT_MILLIS = 5_000L } } + +private data class ConversationScreenDialogUiState( + val attachmentLimitWarning: ConversationAttachmentLimitWarning?, + val isDeleteConversationConfirmationVisible: Boolean, +) diff --git a/src/com/android/messaging/ui/conversation/screen/model/ConversationAttachmentLimitWarning.kt b/src/com/android/messaging/ui/conversation/screen/model/ConversationAttachmentLimitWarning.kt new file mode 100644 index 00000000..b9cd19c0 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/screen/model/ConversationAttachmentLimitWarning.kt @@ -0,0 +1,12 @@ +package com.android.messaging.ui.conversation.screen.model + +import androidx.compose.runtime.Immutable + +@Immutable +internal sealed interface ConversationAttachmentLimitWarning { + data object ComposingAttachmentLimitReached : ConversationAttachmentLimitWarning + + data object SendingMessageLimitReached : ConversationAttachmentLimitWarning + + data object SendingVideoAttachmentLimitReached : ConversationAttachmentLimitWarning +} diff --git a/src/com/android/messaging/ui/conversation/screen/model/ConversationScreenScaffoldUiState.kt b/src/com/android/messaging/ui/conversation/screen/model/ConversationScreenScaffoldUiState.kt index 298922f9..afe6c11e 100644 --- a/src/com/android/messaging/ui/conversation/screen/model/ConversationScreenScaffoldUiState.kt +++ b/src/com/android/messaging/ui/conversation/screen/model/ConversationScreenScaffoldUiState.kt @@ -13,6 +13,7 @@ internal data class ConversationScreenScaffoldUiState( val canUnarchive: Boolean = false, val canAddContact: Boolean = false, val canDeleteConversation: Boolean = false, + val attachmentLimitWarning: ConversationAttachmentLimitWarning? = null, val isDeleteConversationConfirmationVisible: Boolean = false, val metadata: ConversationMetadataUiState = ConversationMetadataUiState.Loading, val messages: ConversationMessagesUiState = ConversationMessagesUiState.Loading, From 71a2383fec8534ca8a0cb2bdb4dc57763b1bec42 Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Sat, 9 May 2026 20:14:59 +0300 Subject: [PATCH 103/136] Add support for MMS subject --- res/values/strings.xml | 7 +- .../android/messaging/debug/TestDataSeeder.kt | 230 +++++++++++++++++- .../ui/conversation/ConversationTestTags.kt | 10 + .../delegate/ConversationDraftDelegate.kt | 29 +++ .../ConversationDraftEditorDelegate.kt | 8 + .../delegate/ConversationDraftEditorState.kt | 13 + .../composer/ui/ConversationComposeBar.kt | 86 +++++-- .../ui/ConversationComposeMessageField.kt | 6 +- .../ui/ConversationComposerSection.kt | 6 + .../composer/ui/ConversationSubjectChip.kt | 69 ++++++ .../ui/message/ConversationMessageBubble.kt | 8 + .../metadata/ui/ConversationTopAppBar.kt | 21 ++ .../conversation/screen/ConversationScreen.kt | 5 + .../screen/ConversationScreenDialogs.kt | 118 +++++++++ .../screen/ConversationViewModel.kt | 31 ++- .../ConversationScreenScaffoldUiState.kt | 2 + 16 files changed, 615 insertions(+), 34 deletions(-) create mode 100644 src/com/android/messaging/ui/conversation/composer/ui/ConversationSubjectChip.kt diff --git a/res/values/strings.xml b/res/values/strings.xml index 2296dd1d..efbb3c9f 100644 --- a/res/values/strings.xml +++ b/res/values/strings.xml @@ -1,5 +1,4 @@ - - Subject + + Subject + + Show subject field Subject:\u0020 diff --git a/src/com/android/messaging/debug/TestDataSeeder.kt b/src/com/android/messaging/debug/TestDataSeeder.kt index 918949d2..a88f22ec 100644 --- a/src/com/android/messaging/debug/TestDataSeeder.kt +++ b/src/com/android/messaging/debug/TestDataSeeder.kt @@ -87,6 +87,10 @@ fun seedTestData(context: Context) { val henry = upsertParticipant(db, "${TEST_PHONE_PREFIX}008901", "Henry Hall", "Henry") val iris = upsertParticipant(db, "${TEST_PHONE_PREFIX}009012", "Iris Ingram", "Iris") val jack = upsertParticipant(db, "${TEST_PHONE_PREFIX}010123", "Jack Johnson", "Jack") + val kim = upsertParticipant(db, "${TEST_PHONE_PREFIX}011234", "Kim Kelly", "Kim") + val liam = upsertParticipant(db, "${TEST_PHONE_PREFIX}012345", "Liam Lewis", "Liam") + val mia = upsertParticipant(db, "${TEST_PHONE_PREFIX}013456", "Mia Miller", "Mia") + val noah = upsertParticipant(db, "${TEST_PHONE_PREFIX}014567", "Noah Nguyen", "Noah") seedScenarioA(db, selfId, alice, now) seedScenarioB(db, selfId, bob, now) @@ -107,6 +111,8 @@ fun seedTestData(context: Context) { now = now, ) seedScenarioI(db, selfId, carol, dave, eve, now) + seedScenarioJ(db, selfId, kim, testImages, now) + seedScenarioK(db, selfId, liam, mia, noah, testImages, now) } MessagingContentProvider.notifyConversationListChanged() @@ -562,6 +568,7 @@ private fun insertImageMessage( timestamp: Long, seen: Boolean = true, read: Boolean = true, + mmsSubject: String? = null, ): Long { return insertAttachmentMessage( db = db, @@ -576,6 +583,7 @@ private fun insertImageMessage( height = 300, seen = seen, read = read, + mmsSubject = mmsSubject, ) } @@ -590,6 +598,7 @@ private fun insertMixedMessage( timestamp: Long, seen: Boolean = true, read: Boolean = true, + mmsSubject: String? = null, ): Long { val messageId = insertAttachmentMessage( db = db, @@ -604,6 +613,7 @@ private fun insertMixedMessage( height = 300, seen = seen, read = read, + mmsSubject = mmsSubject, ) db.insert( DatabaseHelper.PARTS_TABLE, @@ -631,6 +641,7 @@ private fun insertAttachmentMessage( height: Int = 0, seen: Boolean = true, read: Boolean = true, + mmsSubject: String? = null, ): Long { val messageId = insertMessageRow( db = db, @@ -642,7 +653,7 @@ private fun insertAttachmentMessage( timestamp = timestamp, seen = seen, read = read, - mmsSubject = null, + mmsSubject = mmsSubject, ) db.insert( DatabaseHelper.PARTS_TABLE, @@ -1486,3 +1497,220 @@ private fun seedScenarioI( snippetText = latestText, ) } + +/** + * 1:1 MMS thread with Kim covering subject across direction and attachment variants. + * + * Sender row never shows in 1:1 chats, so this scenario isolates direction × content type: + * outgoing text, incoming text, incoming image-only, outgoing image+body — each with subject. + */ +private fun seedScenarioJ( + db: DatabaseWrapper, + selfId: String, + kimId: String, + images: List, + now: Long, +) { + val baseTime = now - 4 * HOURS + val convId = createConversation(db, "Kim Kelly", selfId, listOf(kimId), baseTime) + val img = images[0] + + data class SubjectMsg( + val type: String, + val text: String = "", + val imageUri: String = "", + val isIncoming: Boolean, + val subject: String, + ) + + val messages = listOf( + SubjectMsg( + type = "text", + text = "Did you check the report?", + isIncoming = false, + subject = "Q1 review", + ), + SubjectMsg( + type = "text", + text = "Yes, looks great. A couple of comments on slide 4.", + isIncoming = true, + subject = "Q1 review", + ), + SubjectMsg( + type = "image", + imageUri = img, + isIncoming = true, + subject = "Updated chart", + ), + SubjectMsg( + type = "mixed", + text = "Much clearer now, thanks!", + imageUri = img, + isIncoming = false, + subject = "Updated chart", + ), + ) + + var latestMsgId = 0L + var latestTime = baseTime + var latestText = "" + for ((idx, m) in messages.withIndex()) { + val msgTime = baseTime + idx * 6 * MINUTES + val senderId = if (m.isIncoming) kimId else selfId + val status = if (m.isIncoming) { + MessageData.BUGLE_STATUS_INCOMING_COMPLETE + } else { + MessageData.BUGLE_STATUS_OUTGOING_COMPLETE + } + latestText = m.text.ifBlank { m.subject } + latestMsgId = when (m.type) { + "image" -> insertImageMessage( + db = db, + conversationId = convId, + senderId = senderId, + selfId = selfId, + imageUri = m.imageUri, + status = status, + timestamp = msgTime, + mmsSubject = m.subject, + ) + + "mixed" -> insertMixedMessage( + db = db, + conversationId = convId, + senderId = senderId, + selfId = selfId, + text = m.text, + imageUri = m.imageUri, + status = status, + timestamp = msgTime, + mmsSubject = m.subject, + ) + + else -> insertTextMessage( + db = db, + conversationId = convId, + senderId = senderId, + selfId = selfId, + text = m.text, + status = status, + protocol = MessageData.PROTOCOL_MMS, + timestamp = msgTime, + mmsSubject = m.subject, + ) + } + latestTime = msgTime + } + + finalizeConversation(db, convId, latestMsgId, latestTime, latestText) +} + +/** + * Group MMS thread covering subject combined with sender-display variations. + * + * Sender label is shown only on the first message of an incoming cluster, so this scenario + * mixes a same-sender cluster (label shown then hidden), a sender change (label shown again), + * an outgoing message (no label), and an attachment-with-subject from a fresh sender. + */ +private fun seedScenarioK( + db: DatabaseWrapper, + selfId: String, + liamId: String, + miaId: String, + noahId: String, + images: List, + now: Long, +) { + val baseTime = now - 2 * HOURS + val convId = createConversation( + db, + "Subject Group", + selfId, + listOf(liamId, miaId, noahId), + baseTime, + ) + val img = images[1] + + data class GroupSubjectMsg( + val type: String, + val text: String = "", + val imageUri: String = "", + val senderId: String, + val subject: String, + ) + + val messages = listOf( + GroupSubjectMsg( + type = "text", + text = "Anyone free to review?", + senderId = liamId, + subject = "Design review", + ), + GroupSubjectMsg( + type = "text", + text = "I just added the new mock to the doc", + senderId = liamId, + subject = "Design review", + ), + GroupSubjectMsg( + type = "text", + text = "Looks good — what about the dark mode?", + senderId = miaId, + subject = "Design review", + ), + GroupSubjectMsg( + type = "text", + text = "I'll send screenshots in a minute", + senderId = selfId, + subject = "Design review", + ), + GroupSubjectMsg( + type = "mixed", + text = "Here's the dark version", + imageUri = img, + senderId = noahId, + subject = "Design review", + ), + ) + + var latestMsgId = 0L + var latestTime = baseTime + var latestText = "" + for ((idx, m) in messages.withIndex()) { + val msgTime = baseTime + idx * 6 * MINUTES + val status = if (m.senderId == selfId) { + MessageData.BUGLE_STATUS_OUTGOING_COMPLETE + } else { + MessageData.BUGLE_STATUS_INCOMING_COMPLETE + } + latestText = m.text + latestMsgId = when (m.type) { + "mixed" -> insertMixedMessage( + db = db, + conversationId = convId, + senderId = m.senderId, + selfId = selfId, + text = m.text, + imageUri = m.imageUri, + status = status, + timestamp = msgTime, + mmsSubject = m.subject, + ) + + else -> insertTextMessage( + db = db, + conversationId = convId, + senderId = m.senderId, + selfId = selfId, + text = m.text, + status = status, + protocol = MessageData.PROTOCOL_MMS, + timestamp = msgTime, + mmsSubject = m.subject, + ) + } + latestTime = msgTime + } + + finalizeConversation(db, convId, latestMsgId, latestTime, latestText) +} diff --git a/src/com/android/messaging/ui/conversation/ConversationTestTags.kt b/src/com/android/messaging/ui/conversation/ConversationTestTags.kt index 7614c43e..2bd9fa86 100644 --- a/src/com/android/messaging/ui/conversation/ConversationTestTags.kt +++ b/src/com/android/messaging/ui/conversation/ConversationTestTags.kt @@ -44,6 +44,16 @@ 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 const val CONVERSATION_SHOW_SUBJECT_FIELD_MENU_ITEM_TEST_TAG = + "conversation_show_subject_field_menu_item" +internal const val CONVERSATION_SUBJECT_CHIP_TEST_TAG = "conversation_subject_chip" +internal const val CONVERSATION_SUBJECT_CHIP_CLEAR_BUTTON_TEST_TAG = + "conversation_subject_chip_clear_button" +internal const val CONVERSATION_SUBJECT_DIALOG_TEST_TAG = "conversation_subject_dialog" +internal const val CONVERSATION_SUBJECT_DIALOG_TEXT_FIELD_TEST_TAG = + "conversation_subject_dialog_text_field" +internal const val CONVERSATION_SUBJECT_DIALOG_CLEAR_BUTTON_TEST_TAG = + "conversation_subject_dialog_clear_button" internal fun conversationSimSelectorItemTestTag(selfParticipantId: String): String { return "conversation_sim_selector_item_$selfParticipantId" 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 e199b3df..7768ed16 100644 --- a/src/com/android/messaging/ui/conversation/composer/delegate/ConversationDraftDelegate.kt +++ b/src/com/android/messaging/ui/conversation/composer/delegate/ConversationDraftDelegate.kt @@ -54,9 +54,18 @@ import kotlinx.coroutines.withContext internal interface ConversationDraftDelegate : ConversationScreenDelegate { val effects: Flow val attachmentLimitWarning: StateFlow + val isSubjectDialogVisible: StateFlow fun onMessageTextChanged(messageText: String) + fun onSubjectTextChanged(subjectText: String) + + fun showSubjectDialog() + + fun dismissSubjectDialog() + + fun confirmSubjectDialog(subjectText: String) + fun onSelfParticipantIdChanged(selfParticipantId: String) fun seedDraft( @@ -117,9 +126,11 @@ internal class ConversationDraftDelegateImpl @Inject constructor( private val _attachmentLimitWarning = MutableStateFlow( value = null, ) + private val _isSubjectDialogVisible = MutableStateFlow(value = false) override val effects = _effects.asSharedFlow() override val attachmentLimitWarning = _attachmentLimitWarning.asStateFlow() + override val isSubjectDialogVisible = _isSubjectDialogVisible.asStateFlow() override val state: StateFlow = conversationDraftEditorDelegate.state private val draftSaveMutex = Mutex() @@ -150,6 +161,23 @@ internal class ConversationDraftDelegateImpl @Inject constructor( conversationDraftEditorDelegate.onMessageTextChanged(messageText = messageText) } + override fun onSubjectTextChanged(subjectText: String) { + conversationDraftEditorDelegate.onSubjectTextChanged(subjectText = subjectText) + } + + override fun showSubjectDialog() { + _isSubjectDialogVisible.value = true + } + + override fun dismissSubjectDialog() { + _isSubjectDialogVisible.value = false + } + + override fun confirmSubjectDialog(subjectText: String) { + conversationDraftEditorDelegate.onSubjectTextChanged(subjectText = subjectText) + _isSubjectDialogVisible.value = false + } + override fun onSelfParticipantIdChanged(selfParticipantId: String) { conversationDraftEditorDelegate.onSelfParticipantIdChanged( selfParticipantId = selfParticipantId, @@ -365,6 +393,7 @@ internal class ConversationDraftDelegateImpl @Inject constructor( private suspend fun resetDraftEditorState(conversationId: String?) { pendingMessageLimitSendRequest = null _attachmentLimitWarning.value = null + _isSubjectDialogVisible.value = false val previousSaveRequest = conversationDraftEditorDelegate.reset( conversationId = conversationId, diff --git a/src/com/android/messaging/ui/conversation/composer/delegate/ConversationDraftEditorDelegate.kt b/src/com/android/messaging/ui/conversation/composer/delegate/ConversationDraftEditorDelegate.kt index c6b6df93..3124754c 100644 --- a/src/com/android/messaging/ui/conversation/composer/delegate/ConversationDraftEditorDelegate.kt +++ b/src/com/android/messaging/ui/conversation/composer/delegate/ConversationDraftEditorDelegate.kt @@ -30,6 +30,8 @@ internal interface ConversationDraftEditorDelegate { fun onMessageTextChanged(messageText: String) + fun onSubjectTextChanged(subjectText: String) + fun onSelfParticipantIdChanged(selfParticipantId: String) fun seedDraft( @@ -122,6 +124,12 @@ internal class ConversationDraftEditorDelegateImpl @Inject constructor( } } + override fun onSubjectTextChanged(subjectText: String) { + updateDraftEditorState { currentDraftEditorState -> + currentDraftEditorState.withSubjectText(subjectText) + } + } + override fun onSelfParticipantIdChanged(selfParticipantId: String) { updateDraftEditorState { currentDraftEditorState -> currentDraftEditorState.withSelfParticipantId(selfParticipantId = selfParticipantId) 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 4ceb4fbf..665c6a59 100644 --- a/src/com/android/messaging/ui/conversation/composer/delegate/ConversationDraftEditorState.kt +++ b/src/com/android/messaging/ui/conversation/composer/delegate/ConversationDraftEditorState.kt @@ -68,6 +68,19 @@ internal data class DraftEditorState( } } + fun withSubjectText(subjectText: String): DraftEditorState { + return when { + conversationId == null -> this + effectiveDraft.subjectText == subjectText -> this + + else -> { + copyWithNormalizedLocalEdits( + updatedLocalEdits = localEdits.copy(subjectText = subjectText), + ) + } + } + } + fun withSelfParticipantId(selfParticipantId: String): DraftEditorState { return when { conversationId == null -> this diff --git a/src/com/android/messaging/ui/conversation/composer/ui/ConversationComposeBar.kt b/src/com/android/messaging/ui/conversation/composer/ui/ConversationComposeBar.kt index 03a5308a..fb52b17c 100644 --- a/src/com/android/messaging/ui/conversation/composer/ui/ConversationComposeBar.kt +++ b/src/com/android/messaging/ui/conversation/composer/ui/ConversationComposeBar.kt @@ -10,6 +10,7 @@ import androidx.compose.animation.slideOutHorizontally import androidx.compose.animation.togetherWith 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 @@ -18,6 +19,8 @@ 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.material3.MaterialTheme +import androidx.compose.material3.Surface import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -58,6 +61,7 @@ internal fun ConversationComposeBar( modifier: Modifier = Modifier, audioRecording: ConversationAudioRecordingUiState, messageText: String, + subjectText: String, sendProtocol: ConversationDraftSendProtocol, isMessageFieldEnabled: Boolean, isAttachmentActionEnabled: Boolean, @@ -74,6 +78,8 @@ internal fun ConversationComposeBar( onAudioRecordingLock: () -> Boolean, onAudioRecordingCancel: () -> Unit, onSendClick: () -> Unit, + onSubjectChipClick: () -> Unit, + onSubjectChipClear: () -> Unit, ) { val presentation = rememberConversationComposeBarPresentation() val recordingGestureController = rememberConversationAudioRecordingGestureController( @@ -94,6 +100,7 @@ internal fun ConversationComposeBar( ConversationComposeInputContent( audioRecording = audioRecording, messageText = messageText, + subjectText = subjectText, sendProtocol = sendProtocol, isMessageFieldEnabled = isMessageFieldEnabled, isAttachmentActionEnabled = isAttachmentActionEnabled, @@ -112,6 +119,8 @@ internal fun ConversationComposeBar( onAudioRecordingLock = recordingGestureController.onAudioRecordingLock, onAudioRecordingFinish = recordingGestureController.onAudioRecordingFinish, onSendClick = onSendClick, + onSubjectChipClick = onSubjectChipClick, + onSubjectChipClear = onSubjectChipClear, ) } } @@ -173,6 +182,7 @@ private fun rememberConversationAudioRecordingGestureController( internal fun ConversationComposeInputContent( audioRecording: ConversationAudioRecordingUiState, messageText: String, + subjectText: String, sendProtocol: ConversationDraftSendProtocol, isMessageFieldEnabled: Boolean, isAttachmentActionEnabled: Boolean, @@ -191,6 +201,8 @@ internal fun ConversationComposeInputContent( onAudioRecordingLock: () -> Boolean, onAudioRecordingFinish: (Boolean) -> Unit, onSendClick: () -> Unit, + onSubjectChipClick: () -> Unit, + onSubjectChipClear: () -> Unit, ) { val inputState = conversationComposeInputState( audioRecording = audioRecording, @@ -215,6 +227,7 @@ internal fun ConversationComposeInputContent( ConversationComposeMessageRecordingContent( modifier = Modifier.weight(weight = 1f), messageText = messageText, + subjectText = subjectText, sendProtocol = sendProtocol, durationMillis = audioRecording.durationMillis, inputState = inputState, @@ -227,6 +240,8 @@ internal fun ConversationComposeInputContent( onMediaPickerClick = onMediaPickerClick, onLockedAudioRecordingStartRequest = onLockedAudioRecordingStartRequest, onMessageTextChange = onMessageTextChange, + onSubjectChipClick = onSubjectChipClick, + onSubjectChipClear = onSubjectChipClear, ) ConversationComposeInputSendAction( @@ -326,6 +341,7 @@ private fun conversationComposeInputState( private fun ConversationComposeMessageRecordingContent( modifier: Modifier = Modifier, messageText: String, + subjectText: String, sendProtocol: ConversationDraftSendProtocol, durationMillis: Long, inputState: ConversationComposeInputState, @@ -338,37 +354,53 @@ private fun ConversationComposeMessageRecordingContent( onMediaPickerClick: () -> Unit, onLockedAudioRecordingStartRequest: () -> Unit, onMessageTextChange: (String) -> Unit, + onSubjectChipClick: () -> Unit, + onSubjectChipClear: () -> Unit, ) { - Box( + Surface( modifier = modifier, + shape = presentation.fieldShape, + color = MaterialTheme.colorScheme.surfaceContainerHigh, ) { - ConversationComposeMessageField( - modifier = Modifier.fillMaxWidth(), - value = messageText, - onValueChange = { updatedMessageText -> - if (!inputState.isActiveRecording) { - onMessageTextChange(updatedMessageText) - } - }, - enabled = isMessageFieldEnabled, - sendProtocol = sendProtocol, - isVisuallyHidden = inputState.isActiveRecording, - messageFieldFocusRequester = messageFieldFocusRequester, - presentation = presentation, - isAttachmentActionEnabled = isAttachmentActionEnabled, - isAudioRecordActionEnabled = isRecordActionEnabled, - onContactAttachClick = onContactAttachClick, - onMediaPickerClick = onMediaPickerClick, - onAudioAttachClick = onLockedAudioRecordingStartRequest, - ) + Column { + if (!subjectText.isBlank()) { + ConversationSubjectChip( + subjectText = subjectText, + onClick = onSubjectChipClick, + onClear = onSubjectChipClear, + ) + } - ConversationAudioRecordingContentOverlay( - modifier = Modifier.matchParentSize(), - isActiveRecording = inputState.isActiveRecording, - durationMillis = durationMillis, - cancelProgress = inputState.cancelProgress, - isCancellationArmed = inputState.isCancellationArmed, - ) + Box { + ConversationComposeMessageField( + modifier = Modifier.fillMaxWidth(), + value = messageText, + onValueChange = { updatedMessageText -> + if (!inputState.isActiveRecording) { + onMessageTextChange(updatedMessageText) + } + }, + enabled = isMessageFieldEnabled, + sendProtocol = sendProtocol, + 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, + ) + } + } } } diff --git a/src/com/android/messaging/ui/conversation/composer/ui/ConversationComposeMessageField.kt b/src/com/android/messaging/ui/conversation/composer/ui/ConversationComposeMessageField.kt index 50ae5bd8..75f0e27b 100644 --- a/src/com/android/messaging/ui/conversation/composer/ui/ConversationComposeMessageField.kt +++ b/src/com/android/messaging/ui/conversation/composer/ui/ConversationComposeMessageField.kt @@ -67,9 +67,9 @@ internal fun rememberConversationComposeBarPresentation(): ConversationComposeBa @Composable private fun conversationComposeBarTextFieldColors(): TextFieldColors { return TextFieldDefaults.colors( - focusedContainerColor = MaterialTheme.colorScheme.surfaceContainerHigh, - unfocusedContainerColor = MaterialTheme.colorScheme.surfaceContainerHigh, - disabledContainerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + focusedContainerColor = Color.Transparent, + unfocusedContainerColor = Color.Transparent, + disabledContainerColor = Color.Transparent, focusedIndicatorColor = Color.Transparent, unfocusedIndicatorColor = Color.Transparent, disabledIndicatorColor = Color.Transparent, diff --git a/src/com/android/messaging/ui/conversation/composer/ui/ConversationComposerSection.kt b/src/com/android/messaging/ui/conversation/composer/ui/ConversationComposerSection.kt index bd6edfec..6a8a924c 100644 --- a/src/com/android/messaging/ui/conversation/composer/ui/ConversationComposerSection.kt +++ b/src/com/android/messaging/ui/conversation/composer/ui/ConversationComposerSection.kt @@ -15,6 +15,7 @@ internal fun ConversationComposerSection( audioRecording: ConversationAudioRecordingUiState, attachments: ImmutableList, messageText: String, + subjectText: String, sendProtocol: ConversationDraftSendProtocol, isMessageFieldEnabled: Boolean, isAttachmentActionEnabled: Boolean, @@ -34,6 +35,8 @@ internal fun ConversationComposerSection( onAudioRecordingLock: () -> Boolean, onAudioRecordingCancel: () -> Unit, onSendClick: () -> Unit, + onSubjectChipClick: () -> Unit, + onSubjectChipClear: () -> Unit, ) { Column( modifier = modifier, @@ -48,6 +51,7 @@ internal fun ConversationComposerSection( ConversationComposeBar( audioRecording = audioRecording, messageText = messageText, + subjectText = subjectText, sendProtocol = sendProtocol, isMessageFieldEnabled = isMessageFieldEnabled, isAttachmentActionEnabled = isAttachmentActionEnabled, @@ -64,6 +68,8 @@ internal fun ConversationComposerSection( onAudioRecordingLock = onAudioRecordingLock, onAudioRecordingCancel = onAudioRecordingCancel, onSendClick = onSendClick, + onSubjectChipClick = onSubjectChipClick, + onSubjectChipClear = onSubjectChipClear, ) } } diff --git a/src/com/android/messaging/ui/conversation/composer/ui/ConversationSubjectChip.kt b/src/com/android/messaging/ui/conversation/composer/ui/ConversationSubjectChip.kt new file mode 100644 index 00000000..b4a1f19d --- /dev/null +++ b/src/com/android/messaging/ui/conversation/composer/ui/ConversationSubjectChip.kt @@ -0,0 +1,69 @@ +package com.android.messaging.ui.conversation.composer.ui + +import androidx.compose.foundation.clickable +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.material.icons.Icons +import androidx.compose.material.icons.rounded.Cancel +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +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.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.android.messaging.R +import com.android.messaging.ui.conversation.CONVERSATION_SUBJECT_CHIP_CLEAR_BUTTON_TEST_TAG +import com.android.messaging.ui.conversation.CONVERSATION_SUBJECT_CHIP_TEST_TAG + +private val SUBJECT_CHIP_TEXT_VERTICAL_PADDING = 12.dp + +@Composable +internal fun ConversationSubjectChip( + subjectText: String, + onClick: () -> Unit, + onClear: () -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .testTag(tag = CONVERSATION_SUBJECT_CHIP_TEST_TAG), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + modifier = Modifier + .weight(weight = 1f) + .padding( + start = 16.dp, + top = SUBJECT_CHIP_TEXT_VERTICAL_PADDING, + bottom = SUBJECT_CHIP_TEXT_VERTICAL_PADDING, + ), + text = subjectText, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + + IconButton( + modifier = Modifier + .padding(end = 4.dp) + .testTag(tag = CONVERSATION_SUBJECT_CHIP_CLEAR_BUTTON_TEST_TAG), + onClick = onClear, + ) { + Icon( + modifier = Modifier.size(size = 20.dp), + imageVector = Icons.Rounded.Cancel, + contentDescription = stringResource(R.string.delete_subject_content_description), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} 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 c91d2e7b..3e80ff76 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 @@ -322,6 +322,7 @@ private fun ConversationMessageAttachmentBubbleContent( Text( modifier = Modifier.padding( start = MESSAGE_BUBBLE_MEDIA_TEXT_PADDING, + top = conversationMessageSubjectTopPadding(showSender = layout.showSender), end = MESSAGE_BUBBLE_MEDIA_TEXT_PADDING, bottom = MESSAGE_BUBBLE_MEDIA_SECTION_SPACING, ), @@ -368,6 +369,13 @@ private fun conversationMessageSenderBottomPadding( } } +private fun conversationMessageSubjectTopPadding(showSender: Boolean): Dp { + return when { + showSender -> 0.dp + else -> MESSAGE_BUBBLE_MEDIA_TEXT_PADDING + } +} + @Composable private fun ConversationMessageBody( content: ConversationMessageContent, 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 6a562dba..04583089 100644 --- a/src/com/android/messaging/ui/conversation/metadata/ui/ConversationTopAppBar.kt +++ b/src/com/android/messaging/ui/conversation/metadata/ui/ConversationTopAppBar.kt @@ -11,6 +11,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.ArrowBack +import androidx.compose.material.icons.automirrored.rounded.Subject import androidx.compose.material.icons.rounded.Archive import androidx.compose.material.icons.rounded.Call import androidx.compose.material.icons.rounded.Delete @@ -61,6 +62,7 @@ import com.android.messaging.ui.conversation.CONVERSATION_ARCHIVE_BUTTON_TEST_TA 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_SHOW_SUBJECT_FIELD_MENU_ITEM_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 @@ -83,6 +85,7 @@ internal fun ConversationTopAppBar( isUnarchiveVisible: Boolean = false, isAddContactVisible: Boolean = false, isDeleteConversationVisible: Boolean = false, + isShowSubjectFieldVisible: Boolean = false, simSelector: ConversationSimSelectorUiState = ConversationSimSelectorUiState(), onAddPeopleClick: () -> Unit, onCallClick: () -> Unit = {}, @@ -90,6 +93,7 @@ internal fun ConversationTopAppBar( onUnarchiveClick: () -> Unit = {}, onAddContactClick: () -> Unit = {}, onDeleteConversationClick: () -> Unit = {}, + onShowSubjectFieldClick: () -> Unit = {}, onSimSelectorClick: () -> Unit = {}, onTitleClick: () -> Unit, onNavigateBack: () -> Unit, @@ -104,6 +108,7 @@ internal fun ConversationTopAppBar( isUnarchiveVisible = isUnarchiveVisible, isAddContactVisible = isAddContactVisible, isDeleteConversationVisible = isDeleteConversationVisible, + isShowSubjectFieldVisible = isShowSubjectFieldVisible, isSimSelectorVisible = simSelector.isAvailable, ) @@ -146,6 +151,7 @@ internal fun ConversationTopAppBar( onUnarchiveClick = onUnarchiveClick, onAddContactClick = onAddContactClick, onDeleteConversationClick = onDeleteConversationClick, + onShowSubjectFieldClick = onShowSubjectFieldClick, onSimSelectorClick = onSimSelectorClick, ) }, @@ -250,6 +256,7 @@ private fun ConversationTopAppBarActions( onUnarchiveClick: () -> Unit, onAddContactClick: () -> Unit, onDeleteConversationClick: () -> Unit, + onShowSubjectFieldClick: () -> Unit, onSimSelectorClick: () -> Unit, ) { if (isCallVisible) { @@ -273,6 +280,7 @@ private fun ConversationTopAppBarActions( onUnarchiveClick = onUnarchiveClick, onAddContactClick = onAddContactClick, onDeleteConversationClick = onDeleteConversationClick, + onShowSubjectFieldClick = onShowSubjectFieldClick, onSimSelectorClick = onSimSelectorClick, ) } @@ -287,6 +295,7 @@ private fun ConversationTopAppBarOverflowMenu( onUnarchiveClick: () -> Unit, onAddContactClick: () -> Unit, onDeleteConversationClick: () -> Unit, + onShowSubjectFieldClick: () -> Unit, onSimSelectorClick: () -> Unit, ) { var isExpanded by remember { mutableStateOf(value = false) } @@ -313,6 +322,7 @@ private fun ConversationTopAppBarOverflowMenu( onUnarchiveClick = onUnarchiveClick, onAddContactClick = onAddContactClick, onDeleteConversationClick = onDeleteConversationClick, + onShowSubjectFieldClick = onShowSubjectFieldClick, onSimSelectorClick = onSimSelectorClick, onItemClick = { action -> isExpanded = false @@ -331,6 +341,7 @@ private fun ConversationTopAppBarOverflowMenuContent( onUnarchiveClick: () -> Unit, onAddContactClick: () -> Unit, onDeleteConversationClick: () -> Unit, + onShowSubjectFieldClick: () -> Unit, onSimSelectorClick: () -> Unit, onItemClick: (() -> Unit) -> Unit, ) { @@ -358,6 +369,14 @@ private fun ConversationTopAppBarOverflowMenuContent( onClick = { onItemClick(onAddContactClick) }, ) + ConversationTopAppBarOverflowMenuItem( + isVisible = visibility.isShowSubjectFieldVisible, + testTag = CONVERSATION_SHOW_SUBJECT_FIELD_MENU_ITEM_TEST_TAG, + label = stringResource(id = R.string.conversation_show_subject_field), + icon = Icons.AutoMirrored.Rounded.Subject, + onClick = { onItemClick(onShowSubjectFieldClick) }, + ) + ConversationTopAppBarOverflowMenuItem( isVisible = visibility.isArchiveVisible, testTag = CONVERSATION_ARCHIVE_BUTTON_TEST_TAG, @@ -592,6 +611,7 @@ private data class ConversationTopAppBarOverflowVisibility( val isUnarchiveVisible: Boolean, val isAddContactVisible: Boolean, val isDeleteConversationVisible: Boolean, + val isShowSubjectFieldVisible: Boolean, val isSimSelectorVisible: Boolean, ) { val isOverflowVisible: Boolean @@ -601,6 +621,7 @@ private data class ConversationTopAppBarOverflowVisibility( isUnarchiveVisible || isAddContactVisible || isDeleteConversationVisible || + isShowSubjectFieldVisible || isSimSelectorVisible } } diff --git a/src/com/android/messaging/ui/conversation/screen/ConversationScreen.kt b/src/com/android/messaging/ui/conversation/screen/ConversationScreen.kt index 4452bc1e..719dde3b 100644 --- a/src/com/android/messaging/ui/conversation/screen/ConversationScreen.kt +++ b/src/com/android/messaging/ui/conversation/screen/ConversationScreen.kt @@ -229,6 +229,7 @@ private fun ConversationScreenTopBar( isUnarchiveVisible = uiState.canUnarchive, isAddContactVisible = uiState.canAddContact, isDeleteConversationVisible = uiState.canDeleteConversation, + isShowSubjectFieldVisible = uiState.canEditSubject, simSelector = uiState.composer.simSelector, onAddPeopleClick = onAddPeopleClick, onCallClick = screenModel::onCallClick, @@ -236,6 +237,7 @@ private fun ConversationScreenTopBar( onUnarchiveClick = screenModel::onUnarchiveConversationClick, onAddContactClick = screenModel::onAddContactClick, onDeleteConversationClick = screenModel::onDeleteConversationClick, + onShowSubjectFieldClick = screenModel::onShowSubjectFieldClick, onSimSelectorClick = onSimSelectorClick, onTitleClick = onConversationDetailsClick, onNavigateBack = onNavigateBack, @@ -263,6 +265,7 @@ private fun ConversationScreenBottomBar( audioRecording = uiState.composer.audioRecording, attachments = uiState.composer.attachments, messageText = uiState.composer.messageText, + subjectText = uiState.composer.subjectText, sendProtocol = uiState.composer.sendProtocol, isMessageFieldEnabled = uiState.composer.isMessageFieldEnabled, isAttachmentActionEnabled = uiState.composer.isAttachmentActionEnabled, @@ -282,6 +285,8 @@ private fun ConversationScreenBottomBar( onAudioRecordingLock = screenModel::onAudioRecordingLock, onAudioRecordingCancel = screenModel::onAudioRecordingCancel, onSendClick = screenModel::onSendClick, + onSubjectChipClick = screenModel::onShowSubjectFieldClick, + onSubjectChipClear = screenModel::onSubjectChipClear, ) } diff --git a/src/com/android/messaging/ui/conversation/screen/ConversationScreenDialogs.kt b/src/com/android/messaging/ui/conversation/screen/ConversationScreenDialogs.kt index d52d8352..c06814ea 100644 --- a/src/com/android/messaging/ui/conversation/screen/ConversationScreenDialogs.kt +++ b/src/com/android/messaging/ui/conversation/screen/ConversationScreenDialogs.kt @@ -1,12 +1,34 @@ package com.android.messaging.ui.conversation.screen +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Cancel import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.OutlinedTextField 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 +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.input.TextFieldValue import com.android.messaging.R +import com.android.messaging.ui.conversation.CONVERSATION_SUBJECT_DIALOG_CLEAR_BUTTON_TEST_TAG +import com.android.messaging.ui.conversation.CONVERSATION_SUBJECT_DIALOG_TEST_TAG +import com.android.messaging.ui.conversation.CONVERSATION_SUBJECT_DIALOG_TEXT_FIELD_TEST_TAG import com.android.messaging.ui.conversation.screen.model.ConversationAttachmentLimitWarning import com.android.messaging.ui.conversation.screen.model.ConversationMessageDeleteConfirmationUiState import com.android.messaging.ui.conversation.screen.model.ConversationScreenScaffoldUiState @@ -38,6 +60,14 @@ internal fun ConversationScreenDialogs( onDismiss = screenModel::dismissDeleteConversationConfirmation, ) } + + if (uiState.isSubjectDialogVisible) { + ConversationSubjectFieldDialog( + initialSubjectText = uiState.composer.subjectText, + onConfirm = screenModel::onSubjectDialogConfirm, + onDismiss = screenModel::onSubjectDialogDismiss, + ) + } } @Composable @@ -126,6 +156,94 @@ private fun ConversationDeleteConversationDialog( ) } +@Composable +private fun ConversationSubjectFieldDialog( + initialSubjectText: String, + onConfirm: (String) -> Unit, + onDismiss: () -> Unit, +) { + var fieldText by remember(initialSubjectText) { + mutableStateOf( + value = TextFieldValue( + text = initialSubjectText, + selection = TextRange(index = initialSubjectText.length), + ), + ) + } + + val focusRequester = remember { FocusRequester() } + + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } + + AlertDialog( + modifier = Modifier.testTag(tag = CONVERSATION_SUBJECT_DIALOG_TEST_TAG), + onDismissRequest = onDismiss, + title = { + Text(text = stringResource(R.string.subject_dialog_title)) + }, + text = { + ConversationSubjectFieldInput( + value = fieldText, + onValueChange = { newValue -> fieldText = newValue }, + focusRequester = focusRequester, + ) + }, + confirmButton = { + TextButton(onClick = { onConfirm(fieldText.text) }) { + Text(text = stringResource(android.R.string.ok)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(text = stringResource(android.R.string.cancel)) + } + }, + ) +} + +@Composable +private fun ConversationSubjectFieldInput( + value: TextFieldValue, + onValueChange: (TextFieldValue) -> Unit, + focusRequester: FocusRequester, +) { + OutlinedTextField( + modifier = Modifier + .fillMaxWidth() + .focusRequester(focusRequester = focusRequester) + .testTag(CONVERSATION_SUBJECT_DIALOG_TEXT_FIELD_TEST_TAG), + value = value, + onValueChange = onValueChange, + singleLine = true, + keyboardOptions = KeyboardOptions( + capitalization = KeyboardCapitalization.Sentences, + ), + placeholder = { + Text( + text = stringResource(R.string.compose_message_view_subject_hint_text), + ) + }, + trailingIcon = { + if (value.text.isNotEmpty()) { + IconButton( + modifier = Modifier + .testTag(CONVERSATION_SUBJECT_DIALOG_CLEAR_BUTTON_TEST_TAG), + onClick = { onValueChange(TextFieldValue(text = "")) }, + ) { + Icon( + imageVector = Icons.Rounded.Cancel, + contentDescription = stringResource( + id = R.string.delete_subject_content_description, + ), + ) + } + } + }, + ) +} + @Composable private fun ConversationDeleteMessagesDialog( deleteConfirmation: ConversationMessageDeleteConfirmationUiState, diff --git a/src/com/android/messaging/ui/conversation/screen/ConversationViewModel.kt b/src/com/android/messaging/ui/conversation/screen/ConversationViewModel.kt index 0208f4b9..d31bad65 100644 --- a/src/com/android/messaging/ui/conversation/screen/ConversationViewModel.kt +++ b/src/com/android/messaging/ui/conversation/screen/ConversationViewModel.kt @@ -119,6 +119,11 @@ internal interface ConversationScreenModel { fun confirmDeleteConversation() fun dismissDeleteConversationConfirmation() + fun onShowSubjectFieldClick() + fun onSubjectChipClear() + fun onSubjectDialogConfirm(subjectText: String) + fun onSubjectDialogDismiss() + fun onScreenForegrounded(cancelNotification: Boolean) fun onScreenBackgrounded() } @@ -200,10 +205,12 @@ internal class ConversationViewModel @Inject constructor( private val dialogUiState = combine( conversationDraftDelegate.attachmentLimitWarning, conversationMetadataDelegate.isDeleteConversationConfirmationVisible, - ) { attachmentLimitWarning, isDeleteConversationConfirmationVisible -> + conversationDraftDelegate.isSubjectDialogVisible, + ) { attachmentLimitWarning, isDeleteConversationConfirmationVisible, isSubjectDialogVisible -> ConversationScreenDialogUiState( attachmentLimitWarning = attachmentLimitWarning, isDeleteConversationConfirmationVisible = isDeleteConversationConfirmationVisible, + isSubjectDialogVisible = isSubjectDialogVisible, ) } @@ -222,6 +229,7 @@ internal class ConversationViewModel @Inject constructor( attachmentLimitWarning = dialogUiState.attachmentLimitWarning, isDeleteConversationConfirmationVisible = dialogUiState .isDeleteConversationConfirmationVisible, + isSubjectDialogVisible = dialogUiState.isSubjectDialogVisible, ) }.stateIn( scope = viewModelScope, @@ -236,6 +244,7 @@ internal class ConversationViewModel @Inject constructor( attachmentLimitWarning = conversationDraftDelegate.attachmentLimitWarning.value, isDeleteConversationConfirmationVisible = conversationMetadataDelegate.isDeleteConversationConfirmationVisible.value, + isSubjectDialogVisible = conversationDraftDelegate.isSubjectDialogVisible.value, ), ) @@ -246,6 +255,7 @@ internal class ConversationViewModel @Inject constructor( selectionUiState: ConversationMessageSelectionUiState, attachmentLimitWarning: ConversationAttachmentLimitWarning?, isDeleteConversationConfirmationVisible: Boolean, + isSubjectDialogVisible: Boolean, ): ConversationScreenScaffoldUiState { val isPresent = metadataState is ConversationMetadataUiState.Present val presentMetadata = metadataState as? ConversationMetadataUiState.Present @@ -257,8 +267,10 @@ internal class ConversationViewModel @Inject constructor( canUnarchive = isPresent && presentMetadata?.isArchived == true, canAddContact = canAddContact(metadataState = metadataState), canDeleteConversation = isPresent, + canEditSubject = isPresent, attachmentLimitWarning = attachmentLimitWarning, isDeleteConversationConfirmationVisible = isDeleteConversationConfirmationVisible, + isSubjectDialogVisible = isSubjectDialogVisible, metadata = metadataState, messages = messagesUiState, composer = composerUiState, @@ -714,6 +726,22 @@ internal class ConversationViewModel @Inject constructor( conversationMetadataDelegate.dismissDeleteConversationConfirmation() } + override fun onShowSubjectFieldClick() { + conversationDraftDelegate.showSubjectDialog() + } + + override fun onSubjectChipClear() { + conversationDraftDelegate.onSubjectTextChanged(subjectText = "") + } + + override fun onSubjectDialogConfirm(subjectText: String) { + conversationDraftDelegate.confirmSubjectDialog(subjectText = subjectText) + } + + override fun onSubjectDialogDismiss() { + conversationDraftDelegate.dismissSubjectDialog() + } + override fun onScreenForegrounded(cancelNotification: Boolean) { conversationFocusDelegate.setScreenFocused( focused = true, @@ -743,4 +771,5 @@ internal class ConversationViewModel @Inject constructor( private data class ConversationScreenDialogUiState( val attachmentLimitWarning: ConversationAttachmentLimitWarning?, val isDeleteConversationConfirmationVisible: Boolean, + val isSubjectDialogVisible: Boolean, ) diff --git a/src/com/android/messaging/ui/conversation/screen/model/ConversationScreenScaffoldUiState.kt b/src/com/android/messaging/ui/conversation/screen/model/ConversationScreenScaffoldUiState.kt index afe6c11e..fa1497e6 100644 --- a/src/com/android/messaging/ui/conversation/screen/model/ConversationScreenScaffoldUiState.kt +++ b/src/com/android/messaging/ui/conversation/screen/model/ConversationScreenScaffoldUiState.kt @@ -13,8 +13,10 @@ internal data class ConversationScreenScaffoldUiState( val canUnarchive: Boolean = false, val canAddContact: Boolean = false, val canDeleteConversation: Boolean = false, + val canEditSubject: Boolean = false, val attachmentLimitWarning: ConversationAttachmentLimitWarning? = null, val isDeleteConversationConfirmationVisible: Boolean = false, + val isSubjectDialogVisible: Boolean = false, val metadata: ConversationMetadataUiState = ConversationMetadataUiState.Loading, val messages: ConversationMessagesUiState = ConversationMessagesUiState.Loading, val composer: ConversationComposerUiState = ConversationComposerUiState(), From ce28d615d90ac4fdd7870916fe53bdafde175640 Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Sat, 9 May 2026 21:05:53 +0300 Subject: [PATCH 104/136] Show SIM selection bottom sheet on Send button long press --- .../composer/ui/ConversationComposeBar.kt | 8 +++ .../ui/ConversationComposerSection.kt | 2 + .../ui/ConversationSendActionButton.kt | 2 + .../ui/ConversationSendActionButtonGesture.kt | 71 ++++++++++++++----- .../review/ConversationMediaPickerReview.kt | 1 + .../conversation/screen/ConversationScreen.kt | 28 ++++---- .../screen/ConversationSimSheetState.kt | 58 +++++++++++++++ 7 files changed, 135 insertions(+), 35 deletions(-) create mode 100644 src/com/android/messaging/ui/conversation/screen/ConversationSimSheetState.kt diff --git a/src/com/android/messaging/ui/conversation/composer/ui/ConversationComposeBar.kt b/src/com/android/messaging/ui/conversation/composer/ui/ConversationComposeBar.kt index fb52b17c..063841cf 100644 --- a/src/com/android/messaging/ui/conversation/composer/ui/ConversationComposeBar.kt +++ b/src/com/android/messaging/ui/conversation/composer/ui/ConversationComposeBar.kt @@ -78,6 +78,7 @@ internal fun ConversationComposeBar( onAudioRecordingLock: () -> Boolean, onAudioRecordingCancel: () -> Unit, onSendClick: () -> Unit, + onSendActionLongClick: () -> Unit, onSubjectChipClick: () -> Unit, onSubjectChipClear: () -> Unit, ) { @@ -119,6 +120,7 @@ internal fun ConversationComposeBar( onAudioRecordingLock = recordingGestureController.onAudioRecordingLock, onAudioRecordingFinish = recordingGestureController.onAudioRecordingFinish, onSendClick = onSendClick, + onSendActionLongClick = onSendActionLongClick, onSubjectChipClick = onSubjectChipClick, onSubjectChipClear = onSubjectChipClear, ) @@ -201,6 +203,7 @@ internal fun ConversationComposeInputContent( onAudioRecordingLock: () -> Boolean, onAudioRecordingFinish: (Boolean) -> Unit, onSendClick: () -> Unit, + onSendActionLongClick: () -> Unit, onSubjectChipClick: () -> Unit, onSubjectChipClear: () -> Unit, ) { @@ -248,6 +251,7 @@ internal fun ConversationComposeInputContent( audioRecording = audioRecording, inputState = inputState, onSendClick = onSendClick, + onSendActionLongClick = onSendActionLongClick, onAudioRecordingStartRequest = onAudioRecordingStartRequest, onAudioRecordingDrag = onAudioRecordingDrag, onAudioRecordingLock = onAudioRecordingLock, @@ -262,6 +266,7 @@ private fun ConversationComposeInputSendAction( audioRecording: ConversationAudioRecordingUiState, inputState: ConversationComposeInputState, onSendClick: () -> Unit, + onSendActionLongClick: () -> Unit, onAudioRecordingStartRequest: () -> Unit, onAudioRecordingDrag: (ConversationSendActionButtonGestureState) -> Unit, onAudioRecordingLock: () -> Boolean, @@ -290,6 +295,7 @@ private fun ConversationComposeInputSendAction( onRecordGestureMove = onAudioRecordingDrag, onRecordGestureLock = onAudioRecordingLock, onRecordGestureFinish = onAudioRecordingFinish, + onSendActionLongClick = onSendActionLongClick, ) } @@ -489,6 +495,7 @@ private fun ConversationComposeSendAction( onRecordGestureMove: (ConversationSendActionButtonGestureState) -> Unit, onRecordGestureLock: () -> Boolean, onRecordGestureFinish: (Boolean) -> Unit, + onSendActionLongClick: () -> Unit, ) { Box( modifier = Modifier.heightIn( @@ -508,6 +515,7 @@ private fun ConversationComposeSendAction( onRecordGestureMove = onRecordGestureMove, onRecordGestureLock = onRecordGestureLock, onRecordGestureFinish = onRecordGestureFinish, + onSendActionLongClick = onSendActionLongClick, ) if (shouldShowLockAffordance) { diff --git a/src/com/android/messaging/ui/conversation/composer/ui/ConversationComposerSection.kt b/src/com/android/messaging/ui/conversation/composer/ui/ConversationComposerSection.kt index 6a8a924c..6305dd3a 100644 --- a/src/com/android/messaging/ui/conversation/composer/ui/ConversationComposerSection.kt +++ b/src/com/android/messaging/ui/conversation/composer/ui/ConversationComposerSection.kt @@ -35,6 +35,7 @@ internal fun ConversationComposerSection( onAudioRecordingLock: () -> Boolean, onAudioRecordingCancel: () -> Unit, onSendClick: () -> Unit, + onSendActionLongClick: () -> Unit, onSubjectChipClick: () -> Unit, onSubjectChipClear: () -> Unit, ) { @@ -68,6 +69,7 @@ internal fun ConversationComposerSection( onAudioRecordingLock = onAudioRecordingLock, onAudioRecordingCancel = onAudioRecordingCancel, onSendClick = onSendClick, + onSendActionLongClick = onSendActionLongClick, onSubjectChipClick = onSubjectChipClick, onSubjectChipClear = onSubjectChipClear, ) diff --git a/src/com/android/messaging/ui/conversation/composer/ui/ConversationSendActionButton.kt b/src/com/android/messaging/ui/conversation/composer/ui/ConversationSendActionButton.kt index 395c77f1..c6ae62b2 100644 --- a/src/com/android/messaging/ui/conversation/composer/ui/ConversationSendActionButton.kt +++ b/src/com/android/messaging/ui/conversation/composer/ui/ConversationSendActionButton.kt @@ -102,6 +102,7 @@ internal fun ConversationSendActionButton( onRecordGestureMove: (ConversationSendActionButtonGestureState) -> Unit, onRecordGestureLock: () -> Boolean, onRecordGestureFinish: (Boolean) -> Unit, + onSendActionLongClick: () -> Unit, ) { var isRecordGestureActive by remember(mode, enabled) { mutableStateOf(value = false) @@ -134,6 +135,7 @@ internal fun ConversationSendActionButton( onRecordGestureLock = onRecordGestureLock, onRecordGestureFinish = onRecordGestureFinish, onLockedStopClick = onLockedStopClick, + onSendActionLongClick = onSendActionLongClick, ) ConversationSendActionButtonLayout( diff --git a/src/com/android/messaging/ui/conversation/composer/ui/ConversationSendActionButtonGesture.kt b/src/com/android/messaging/ui/conversation/composer/ui/ConversationSendActionButtonGesture.kt index a9a2bc5f..fba754cf 100644 --- a/src/com/android/messaging/ui/conversation/composer/ui/ConversationSendActionButtonGesture.kt +++ b/src/com/android/messaging/ui/conversation/composer/ui/ConversationSendActionButtonGesture.kt @@ -7,10 +7,13 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Modifier +import androidx.compose.ui.hapticfeedback.HapticFeedback +import androidx.compose.ui.hapticfeedback.HapticFeedbackType 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.LocalHapticFeedback import com.android.messaging.ui.conversation.composer.model.ConversationSendActionButtonGestureState import com.android.messaging.ui.conversation.composer.model.ConversationSendActionButtonMode @@ -28,6 +31,7 @@ internal fun Modifier.conversationSendActionButtonGesture( onRecordGestureLock: () -> Boolean, onRecordGestureFinish: (Boolean) -> Unit, onLockedStopClick: () -> Unit, + onSendActionLongClick: () -> Unit, ): Modifier { val currentIsRecordingActive by rememberUpdatedState(newValue = isRecordingActive) val currentIsRecordingLocked by rememberUpdatedState(newValue = isRecordingLocked) @@ -37,8 +41,10 @@ internal fun Modifier.conversationSendActionButtonGesture( val currentOnRecordGestureLock by rememberUpdatedState(newValue = onRecordGestureLock) val currentOnRecordGestureFinish by rememberUpdatedState(newValue = onRecordGestureFinish) val currentOnLockedStopClick by rememberUpdatedState(newValue = onLockedStopClick) + val currentOnSendActionLongClick by rememberUpdatedState(newValue = onSendActionLongClick) + val hapticFeedback = LocalHapticFeedback.current - if (mode == ConversationSendActionButtonMode.Send || !enabled) { + if (mode != ConversationSendActionButtonMode.Send && !enabled) { return this } @@ -48,29 +54,56 @@ internal fun Modifier.conversationSendActionButtonGesture( 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, - ) + val isLockedRecording = currentIsRecordingActive && currentIsRecordingLocked + + when { + mode == ConversationSendActionButtonMode.Send -> { + handleSendModeLongPress( + hapticFeedback = hapticFeedback, + onSendActionLongClick = currentOnSendActionLongClick, + ) + } + + isLockedRecording -> { + 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, + ) + } } } } } +private suspend fun AwaitPointerEventScope.handleSendModeLongPress( + hapticFeedback: HapticFeedback, + onSendActionLongClick: () -> Unit, +) { + val initialDown = awaitFirstDown(requireUnconsumed = false) + + val longPressChange = awaitLongPressOrCancellation(pointerId = initialDown.id) + ?: return + + longPressChange.consume() + hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) + onSendActionLongClick() +} + private suspend fun AwaitPointerEventScope.handleRecordGesture( cancelThresholdPx: Float, lockThresholdPx: Float, diff --git a/src/com/android/messaging/ui/conversation/mediapicker/component/review/ConversationMediaPickerReview.kt b/src/com/android/messaging/ui/conversation/mediapicker/component/review/ConversationMediaPickerReview.kt index 8414e312..fabc9158 100644 --- a/src/com/android/messaging/ui/conversation/mediapicker/component/review/ConversationMediaPickerReview.kt +++ b/src/com/android/messaging/ui/conversation/mediapicker/component/review/ConversationMediaPickerReview.kt @@ -483,6 +483,7 @@ private fun ConversationMediaReviewBottomBar( onRecordGestureMove = { _ -> }, onRecordGestureLock = { false }, onRecordGestureFinish = {}, + onSendActionLongClick = {}, ) } } diff --git a/src/com/android/messaging/ui/conversation/screen/ConversationScreen.kt b/src/com/android/messaging/ui/conversation/screen/ConversationScreen.kt index 719dde3b..3ccc3334 100644 --- a/src/com/android/messaging/ui/conversation/screen/ConversationScreen.kt +++ b/src/com/android/messaging/ui/conversation/screen/ConversationScreen.kt @@ -141,14 +141,9 @@ internal fun ConversationScreenScaffold( onLockedAudioRecordingStartRequest: () -> Unit, screenModel: ConversationScreenModel, ) { - var isSimSheetVisible by rememberSaveable { mutableStateOf(value = false) } - - val hasSimSelector = uiState.composer.simSelector.isAvailable - LaunchedEffect(hasSimSelector) { - if (!hasSimSelector) { - isSimSheetVisible = false - } - } + val simSheetState = rememberConversationSimSheetState( + isAvailable = uiState.composer.simSelector.isAvailable, + ) Scaffold( modifier = modifier, @@ -159,7 +154,7 @@ internal fun ConversationScreenScaffold( onAddPeopleClick = onAddPeopleClick, onConversationDetailsClick = onConversationDetailsClick, onNavigateBack = onNavigateBack, - onSimSelectorClick = { isSimSheetVisible = true }, + onSimSelectorClick = simSheetState::show, screenModel = screenModel, ) }, @@ -172,6 +167,7 @@ internal fun ConversationScreenScaffold( onOpenMediaPicker = onOpenMediaPicker, onAudioRecordingStartRequest = onAudioRecordingStartRequest, onLockedAudioRecordingStartRequest = onLockedAudioRecordingStartRequest, + onSendActionLongClick = simSheetState::show, screenModel = screenModel, ) }, @@ -195,10 +191,9 @@ internal fun ConversationScreenScaffold( ConversationScreenDialogs(uiState = uiState, screenModel = screenModel) ConversationScreenSimSelectorSheet( - isVisible = isSimSheetVisible, + simSheetState = simSheetState, uiState = uiState, onSimSelected = screenModel::onSimSelected, - onDismissRequest = { isSimSheetVisible = false }, ) } @@ -255,6 +250,7 @@ private fun ConversationScreenBottomBar( onOpenMediaPicker: () -> Unit, onAudioRecordingStartRequest: () -> Unit, onLockedAudioRecordingStartRequest: () -> Unit, + onSendActionLongClick: () -> Unit, screenModel: ConversationScreenModel, ) { if (isMediaPickerOpen) { @@ -285,6 +281,7 @@ private fun ConversationScreenBottomBar( onAudioRecordingLock = screenModel::onAudioRecordingLock, onAudioRecordingCancel = screenModel::onAudioRecordingCancel, onSendClick = screenModel::onSendClick, + onSendActionLongClick = onSendActionLongClick, onSubjectChipClick = screenModel::onShowSubjectFieldClick, onSubjectChipClear = screenModel::onSubjectChipClear, ) @@ -292,12 +289,11 @@ private fun ConversationScreenBottomBar( @Composable private fun ConversationScreenSimSelectorSheet( - isVisible: Boolean, + simSheetState: ConversationSimSheetState, uiState: ConversationScreenScaffoldUiState, onSimSelected: (String) -> Unit, - onDismissRequest: () -> Unit, ) { - if (!isVisible || !uiState.composer.simSelector.isAvailable) { + if (!simSheetState.isVisible || !uiState.composer.simSelector.isAvailable) { return } @@ -305,9 +301,9 @@ private fun ConversationScreenSimSelectorSheet( uiState = uiState.composer.simSelector, onSimSelected = { selfParticipantId -> onSimSelected(selfParticipantId) - onDismissRequest() + simSheetState.dismiss() }, - onDismissRequest = onDismissRequest, + onDismissRequest = simSheetState::dismiss, ) } diff --git a/src/com/android/messaging/ui/conversation/screen/ConversationSimSheetState.kt b/src/com/android/messaging/ui/conversation/screen/ConversationSimSheetState.kt new file mode 100644 index 00000000..89800081 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/screen/ConversationSimSheetState.kt @@ -0,0 +1,58 @@ +package com.android.messaging.ui.conversation.screen + +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.saveable.Saver +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue + +@Stable +internal class ConversationSimSheetState { + var isVisible: Boolean by mutableStateOf(value = false) + private set + + internal var isAvailable: Boolean = false + + fun show() { + if (isAvailable) { + isVisible = true + } + } + + fun dismiss() { + isVisible = false + } + + companion object { + val Saver: Saver = Saver( + save = { state -> state.isVisible }, + restore = { restoredIsVisible -> + ConversationSimSheetState().apply { + isVisible = restoredIsVisible + } + }, + ) + } +} + +@Composable +internal fun rememberConversationSimSheetState( + isAvailable: Boolean, +): ConversationSimSheetState { + val state = rememberSaveable(saver = ConversationSimSheetState.Saver) { + ConversationSimSheetState() + } + + state.isAvailable = isAvailable + + LaunchedEffect(isAvailable) { + if (!isAvailable) { + state.dismiss() + } + } + + return state +} From e98e855574e86219eb03baf7eb97d72fcebf7e2d Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Sat, 9 May 2026 21:26:49 +0300 Subject: [PATCH 105/136] Improve SIM selection item in the conversation overflow menu --- res/values/strings.xml | 2 ++ .../metadata/ui/ConversationTopAppBar.kt | 21 +++++++++++++++++-- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/res/values/strings.xml b/res/values/strings.xml index efbb3c9f..570506ef 100644 --- a/res/values/strings.xml +++ b/res/values/strings.xml @@ -666,6 +666,8 @@ Subject Show subject field + + Switch SIMs Subject:\u0020 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 04583089..b3d5ae77 100644 --- a/src/com/android/messaging/ui/conversation/metadata/ui/ConversationTopAppBar.kt +++ b/src/com/android/messaging/ui/conversation/metadata/ui/ConversationTopAppBar.kt @@ -348,7 +348,8 @@ private fun ConversationTopAppBarOverflowMenuContent( ConversationTopAppBarOverflowMenuItem( isVisible = visibility.isSimSelectorVisible, testTag = CONVERSATION_SIM_SELECTOR_MENU_ITEM_TEST_TAG, - label = simSelectorLabel, + label = stringResource(R.string.conversation_switch_sims), + secondaryLabel = simSelectorLabel, icon = Icons.Rounded.SimCard, onClick = { onItemClick(onSimSelectorClick) }, ) @@ -409,6 +410,7 @@ private fun ConversationTopAppBarOverflowMenuItem( label: String, icon: ImageVector, onClick: () -> Unit, + secondaryLabel: String? = null, ) { if (!isVisible) { return @@ -417,7 +419,22 @@ private fun ConversationTopAppBarOverflowMenuItem( DropdownMenuItem( modifier = Modifier.testTag(tag = testTag), text = { - Text(text = label) + when { + secondaryLabel.isNullOrEmpty() -> { + Text(text = label) + } + + else -> { + Column { + Text(text = label) + Text( + text = secondaryLabel, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } }, leadingIcon = { Icon( From f6cd4a0e762808ca3a2e359d0dad0432e35e9ee0 Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Sun, 10 May 2026 02:29:06 +0300 Subject: [PATCH 106/136] SIM indication for messages --- .../ConversationMessageLinkLongClickTest.kt | 1 + ...veConversationMessageSimDisplayNameTest.kt | 146 ++++++++++++++++++ res/values/strings.xml | 3 + .../android/messaging/debug/TestDataSeeder.kt | 123 +++++++++++++++ .../ConversationSubscriptionLabelResolver.kt | 19 +-- .../ConversationMessageUiModelMapper.kt | 1 + .../message/ConversationMessageUiModel.kt | 1 + .../messages/ui/ConversationMessages.kt | 42 +++++ .../ui/message/ConversationMessage.kt | 8 + .../ui/message/ConversationMessageMetadata.kt | 137 ++++++++++++++-- .../ui/message/ConversationMessageRows.kt | 4 + .../ConversationMessageSimAnnotation.kt | 22 +++ .../conversation/screen/ConversationScreen.kt | 4 + 13 files changed, 490 insertions(+), 21 deletions(-) create mode 100644 app/src/test/kotlin/com/android/messaging/ui/conversation/messages/ui/message/ResolveConversationMessageSimDisplayNameTest.kt create mode 100644 src/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessageSimAnnotation.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 index a4479063..19729363 100644 --- 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 @@ -160,6 +160,7 @@ private fun outgoingMessage(text: String): ConversationMessageUiModel { senderDisplayName = null, senderAvatarUri = null, senderContactLookupKey = null, + selfParticipantId = null, canClusterWithPrevious = false, canClusterWithNext = false, canCopyMessageToClipboard = true, diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/ui/message/ResolveConversationMessageSimDisplayNameTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/ui/message/ResolveConversationMessageSimDisplayNameTest.kt new file mode 100644 index 00000000..1d475fea --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/ui/message/ResolveConversationMessageSimDisplayNameTest.kt @@ -0,0 +1,146 @@ +package com.android.messaging.ui.conversation.messages.ui.message + +import com.android.messaging.ui.conversation.messages.model.message.ConversationMessagePartUiModel +import com.android.messaging.ui.conversation.messages.model.message.ConversationMessageUiModel +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +private const val SIM_1_ID = "self-sim-1" +private const val SIM_2_ID = "self-sim-2" +private const val SIM_1_NAME = "SIM 1" +private const val SIM_2_NAME = "SIM 2" + +private val SIM_DISPLAY_NAMES = mapOf( + SIM_1_ID to SIM_1_NAME, + SIM_2_ID to SIM_2_NAME, +) + +class ResolveConversationMessageSimDisplayNameTest { + + @Test + fun returnsNullWhenSingleSubscription() { + val message = message(selfParticipantId = SIM_1_ID, isIncoming = false) + + val result = resolveConversationMessageSimDisplayName( + message = message, + messageBelow = null, + simDisplayNameByParticipantId = mapOf(SIM_1_ID to SIM_1_NAME), + ) + + assertNull(result) + } + + @Test + fun returnsNullWhenMessageHasNoSelfParticipantId() { + val message = message(selfParticipantId = null, isIncoming = true) + + val result = resolveConversationMessageSimDisplayName( + message = message, + messageBelow = null, + simDisplayNameByParticipantId = SIM_DISPLAY_NAMES, + ) + + assertNull(result) + } + + @Test + fun returnsNullWhenSelfParticipantIdMissingFromMap() { + val message = message(selfParticipantId = "unknown", isIncoming = false) + + val result = resolveConversationMessageSimDisplayName( + message = message, + messageBelow = null, + simDisplayNameByParticipantId = SIM_DISPLAY_NAMES, + ) + + assertNull(result) + } + + @Test + fun returnsDisplayNameWhenLastMessageInConversation() { + val message = message(selfParticipantId = SIM_2_ID, isIncoming = false) + + val result = resolveConversationMessageSimDisplayName( + message = message, + messageBelow = null, + simDisplayNameByParticipantId = SIM_DISPLAY_NAMES, + ) + + assertEquals(SIM_2_NAME, result) + } + + @Test + fun returnsDisplayNameWhenNextMessageUsesDifferentSim() { + val message = message(selfParticipantId = SIM_1_ID, isIncoming = false) + val below = message(selfParticipantId = SIM_2_ID, isIncoming = false) + + val result = resolveConversationMessageSimDisplayName( + message = message, + messageBelow = below, + simDisplayNameByParticipantId = SIM_DISPLAY_NAMES, + ) + + assertEquals(SIM_1_NAME, result) + } + + @Test + fun returnsDisplayNameWhenNextMessageHasOppositeDirection() { + val message = message(selfParticipantId = SIM_1_ID, isIncoming = false) + val below = message(selfParticipantId = SIM_1_ID, isIncoming = true) + + val result = resolveConversationMessageSimDisplayName( + message = message, + messageBelow = below, + simDisplayNameByParticipantId = SIM_DISPLAY_NAMES, + ) + + assertEquals(SIM_1_NAME, result) + } + + @Test + fun returnsNullWhenInsideContiguousSimRun() { + val message = message(selfParticipantId = SIM_1_ID, isIncoming = false) + val below = message(selfParticipantId = SIM_1_ID, isIncoming = false) + + val result = resolveConversationMessageSimDisplayName( + message = message, + messageBelow = below, + simDisplayNameByParticipantId = SIM_DISPLAY_NAMES, + ) + + assertNull(result) + } +} + +private fun message( + selfParticipantId: String?, + isIncoming: Boolean, +): ConversationMessageUiModel { + return ConversationMessageUiModel( + messageId = "id-${selfParticipantId.orEmpty()}-$isIncoming", + conversationId = "conversation", + text = "text", + parts = listOf( + ConversationMessagePartUiModel.Text(text = "text"), + ), + sentTimestamp = 0L, + receivedTimestamp = 0L, + displayTimestamp = 0L, + status = ConversationMessageUiModel.Status.Outgoing.Complete, + isIncoming = isIncoming, + senderDisplayName = null, + senderAvatarUri = null, + senderContactLookupKey = null, + selfParticipantId = selfParticipantId, + canClusterWithPrevious = false, + canClusterWithNext = false, + canCopyMessageToClipboard = false, + canDownloadMessage = false, + canForwardMessage = false, + canResendMessage = false, + canSaveAttachments = false, + mmsSubject = null, + protocol = ConversationMessageUiModel.Protocol.SMS, + ) +} diff --git a/res/values/strings.xml b/res/values/strings.xml index 570506ef..87b23f1b 100644 --- a/res/values/strings.xml +++ b/res/values/strings.xml @@ -965,6 +965,9 @@ Debug SIM %s + + via %1$s + Edit subject diff --git a/src/com/android/messaging/debug/TestDataSeeder.kt b/src/com/android/messaging/debug/TestDataSeeder.kt index a88f22ec..e75455c9 100644 --- a/src/com/android/messaging/debug/TestDataSeeder.kt +++ b/src/com/android/messaging/debug/TestDataSeeder.kt @@ -12,6 +12,7 @@ import android.graphics.Paint import android.net.Uri import androidx.core.graphics.createBitmap import androidx.core.net.toUri +import com.android.messaging.data.conversation.repository.ConversationSubscriptionsRepository import com.android.messaging.datamodel.DataModel import com.android.messaging.datamodel.DatabaseHelper import com.android.messaging.datamodel.DatabaseHelper.ConversationColumns @@ -27,6 +28,10 @@ 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 dagger.hilt.EntryPoint +import dagger.hilt.InstallIn +import dagger.hilt.android.EntryPointAccessors +import dagger.hilt.components.SingletonComponent import java.io.BufferedOutputStream import java.io.ByteArrayOutputStream import java.io.DataOutputStream @@ -34,6 +39,8 @@ import java.io.File import java.io.FileOutputStream import kotlin.math.PI import kotlin.math.sin +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking private const val TAG = "TestDataSeeder" private const val TEST_PHONE_PREFIX = "+15550" @@ -60,6 +67,12 @@ private data class SeedVCards( val locationUri: String, ) +@EntryPoint +@InstallIn(SingletonComponent::class) +private interface SeedSubscriptionsEntryPoint { + fun subscriptionsRepository(): ConversationSubscriptionsRepository +} + fun seedTestData(context: Context) { clearSeededTestData(context = context) @@ -70,6 +83,8 @@ fun seedTestData(context: Context) { return } + val (simAId, simBId) = resolveDualSimSelfIds(context = context) + val testImages = buildTestImages(context) val testAudio = buildTestAudio() val testVideo = buildTestVideo(context) @@ -91,6 +106,7 @@ fun seedTestData(context: Context) { val liam = upsertParticipant(db, "${TEST_PHONE_PREFIX}012345", "Liam Lewis", "Liam") val mia = upsertParticipant(db, "${TEST_PHONE_PREFIX}013456", "Mia Miller", "Mia") val noah = upsertParticipant(db, "${TEST_PHONE_PREFIX}014567", "Noah Nguyen", "Noah") + val olivia = upsertParticipant(db, "${TEST_PHONE_PREFIX}015678", "Olivia Ortega", "Olivia") seedScenarioA(db, selfId, alice, now) seedScenarioB(db, selfId, bob, now) @@ -113,12 +129,44 @@ fun seedTestData(context: Context) { seedScenarioI(db, selfId, carol, dave, eve, now) seedScenarioJ(db, selfId, kim, testImages, now) seedScenarioK(db, selfId, liam, mia, noah, testImages, now) + if (simAId != null && simBId != null) { + seedScenarioL( + db = db, + realSelfId = selfId, + simAId = simAId, + simBId = simBId, + oliviaId = olivia, + now = now, + ) + } } MessagingContentProvider.notifyConversationListChanged() LogUtil.d(TAG, "Test data seeded successfully") } +private fun resolveDualSimSelfIds(context: Context): Pair { + DebugSimEmulationStore.setMode(mode = DebugSimEmulationMode.DUAL) + + val repository = EntryPointAccessors + .fromApplication( + context.applicationContext, + SeedSubscriptionsEntryPoint::class.java, + ) + .subscriptionsRepository() + + val subscriptions = runCatching { + runBlocking { + repository.observeActiveSubscriptions().first { it.size >= 2 } + } + }.getOrElse { throwable -> + LogUtil.w(TAG, "Failed to resolve dual SIM subscriptions for seeding", throwable) + return null to null + } + + return subscriptions[0].selfParticipantId to subscriptions[1].selfParticipantId +} + fun clearSeededTestData(context: Context) { val db = DataModel.get().getDatabase() val seededAttachmentUris = mutableSetOf() @@ -1605,6 +1653,81 @@ private fun seedScenarioJ( finalizeConversation(db, convId, latestMsgId, latestTime, latestText) } +/** + * 1:1 SMS thread with Olivia exercising every branch of the SIM annotation rule. + * + * The annotation appears on the LAST message of each contiguous SIM run. The sequence below + * walks: a 3-message SIM-A burst (annotation only on the 3rd), a single SIM-B run, a 2-message + * SIM-A burst, a 2-message incoming SIM-B burst (direction change ends the run), and a final + * outgoing SIM-A message (last in conversation). + * + * Outgoing rows keep [realSelfId] as their sender so the participants table FK remains valid; + * only [MessageColumns.SELF_PARTICIPANT_ID] varies between [simAId] and [simBId]. + */ +private fun seedScenarioL( + db: DatabaseWrapper, + realSelfId: String, + simAId: String, + simBId: String, + oliviaId: String, + now: Long, +) { + val baseTime = now - 90 * MINUTES + val convId = createConversation( + db = db, + name = "Olivia Ortega", + selfId = simAId, + participantIds = listOf(oliviaId), + sortTimestamp = baseTime, + ) + + data class SimMixMessage( + val text: String, + val isIncoming: Boolean, + val simSelfId: String, + val offsetMillis: Long, + ) + + val messages = listOf( + SimMixMessage("Heads up — switching SIMs today", false, simAId, 0L), + SimMixMessage("Two more on this number", false, simAId, 30_000L), + SimMixMessage("Third one wraps the SIM 1 burst", false, simAId, 60_000L), + SimMixMessage("Now sending from SIM 2 just once", false, simBId, 6 * MINUTES), + SimMixMessage("Back to SIM 1", false, simAId, 12 * MINUTES), + SimMixMessage("Still on SIM 1", false, simAId, 12 * MINUTES + 30_000L), + SimMixMessage("Got it — replying on SIM 2", true, simBId, 18 * MINUTES), + SimMixMessage("And one more reply", true, simBId, 18 * MINUTES + 30_000L), + SimMixMessage("Last message — back on SIM 1", false, simAId, 24 * MINUTES), + ) + + var latestMsgId = 0L + var latestTime = baseTime + var latestText = "" + for (message in messages) { + val msgTime = baseTime + message.offsetMillis + val senderId = if (message.isIncoming) oliviaId else realSelfId + val status = if (message.isIncoming) { + MessageData.BUGLE_STATUS_INCOMING_COMPLETE + } else { + MessageData.BUGLE_STATUS_OUTGOING_COMPLETE + } + latestText = message.text + latestMsgId = insertTextMessage( + db = db, + conversationId = convId, + senderId = senderId, + selfId = message.simSelfId, + text = message.text, + status = status, + protocol = MessageData.PROTOCOL_SMS, + timestamp = msgTime, + ) + latestTime = msgTime + } + + finalizeConversation(db, convId, latestMsgId, latestTime, latestText) +} + /** * Group MMS thread covering subject combined with sender-display variations. * diff --git a/src/com/android/messaging/ui/conversation/ConversationSubscriptionLabelResolver.kt b/src/com/android/messaging/ui/conversation/ConversationSubscriptionLabelResolver.kt index e5e1f7bc..eaf07203 100644 --- a/src/com/android/messaging/ui/conversation/ConversationSubscriptionLabelResolver.kt +++ b/src/com/android/messaging/ui/conversation/ConversationSubscriptionLabelResolver.kt @@ -1,27 +1,28 @@ package com.android.messaging.ui.conversation +import android.content.res.Resources import androidx.compose.runtime.Composable -import androidx.compose.ui.res.stringResource +import androidx.compose.ui.platform.LocalResources import com.android.messaging.R import com.android.messaging.data.conversation.model.metadata.ConversationSubscriptionLabel @Composable internal fun ConversationSubscriptionLabel.resolveDisplayName(): String { + return resolveDisplayName(resources = LocalResources.current) +} + +internal fun ConversationSubscriptionLabel.resolveDisplayName( + resources: Resources, +): String { return when (this) { is ConversationSubscriptionLabel.Named -> name is ConversationSubscriptionLabel.Slot -> { - stringResource( - id = R.string.sim_slot_identifier, - slotId.toString(), - ) + resources.getString(R.string.sim_slot_identifier, slotId.toString()) } is ConversationSubscriptionLabel.DebugFake -> { - stringResource( - id = R.string.debug_emulated_sim_display_name, - slotId.toString(), - ) + resources.getString(R.string.debug_emulated_sim_display_name, slotId.toString()) } } } diff --git a/src/com/android/messaging/ui/conversation/messages/mapper/ConversationMessageUiModelMapper.kt b/src/com/android/messaging/ui/conversation/messages/mapper/ConversationMessageUiModelMapper.kt index 6a317dd0..cbf249f5 100644 --- a/src/com/android/messaging/ui/conversation/messages/mapper/ConversationMessageUiModelMapper.kt +++ b/src/com/android/messaging/ui/conversation/messages/mapper/ConversationMessageUiModelMapper.kt @@ -37,6 +37,7 @@ internal class ConversationMessageUiModelMapperImpl @Inject constructor( senderDisplayName = data.senderDisplayName, senderAvatarUri = data.senderProfilePhotoUri, senderContactLookupKey = data.senderContactLookupKey, + selfParticipantId = data.selfParticipantId?.takeIf { it.isNotBlank() }, canClusterWithPrevious = data.canClusterWithPreviousMessage, canClusterWithNext = data.canClusterWithNextMessage, canCopyMessageToClipboard = data.canCopyMessageToClipboard, diff --git a/src/com/android/messaging/ui/conversation/messages/model/message/ConversationMessageUiModel.kt b/src/com/android/messaging/ui/conversation/messages/model/message/ConversationMessageUiModel.kt index d93d016a..4a821343 100644 --- a/src/com/android/messaging/ui/conversation/messages/model/message/ConversationMessageUiModel.kt +++ b/src/com/android/messaging/ui/conversation/messages/model/message/ConversationMessageUiModel.kt @@ -17,6 +17,7 @@ internal data class ConversationMessageUiModel( val senderDisplayName: String?, val senderAvatarUri: Uri?, val senderContactLookupKey: String?, + val selfParticipantId: String?, val canClusterWithPrevious: Boolean, val canClusterWithNext: Boolean, val canCopyMessageToClipboard: Boolean, 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 6189c302..4791073e 100644 --- a/src/com/android/messaging/ui/conversation/messages/ui/ConversationMessages.kt +++ b/src/com/android/messaging/ui/conversation/messages/ui/ConversationMessages.kt @@ -19,18 +19,23 @@ 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.LocalResources import androidx.compose.ui.platform.testTag import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import com.android.messaging.data.conversation.model.metadata.ConversationSubscription 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 com.android.messaging.ui.conversation.messages.ui.message.resolveConversationMessageSimDisplayName +import com.android.messaging.ui.conversation.resolveDisplayName import java.util.TimeZone import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableSet +import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentSetOf private val CONVERSATION_MESSAGES_CONTENT_PADDING = PaddingValues( @@ -60,13 +65,16 @@ internal fun ConversationMessages( listState: LazyListState, selectedMessageIds: ImmutableSet = persistentSetOf(), showIncomingSenderLabels: Boolean = true, + subscriptions: ImmutableList = persistentListOf(), onAttachmentClick: (contentType: String, contentUri: String) -> Unit, onExternalUriClick: (String) -> Unit, onMessageClick: (String) -> Unit, onMessageLongClick: (String) -> Unit, onMessageResendClick: (String) -> Unit, + onSimSelectorClick: () -> Unit = {}, ) { val configuration = LocalConfiguration.current + val resources = LocalResources.current val displayMessages = remember(messages) { messages.asReversed() } @@ -74,6 +82,14 @@ internal fun ConversationMessages( TimeZone.getDefault() } + val simDisplayNameByParticipantId = remember(subscriptions, resources) { + subscriptions.associate { subscription -> + subscription.selfParticipantId to subscription.label.resolveDisplayName( + resources = resources, + ) + } + } + LazyColumn( state = listState, reverseLayout = true, @@ -100,14 +116,20 @@ internal fun ConversationMessages( messages = displayMessages, index = index, ), + messageBelow = messageBelowCurrent( + messages = displayMessages, + index = index, + ), isSelectionMode = selectedMessageIds.isNotEmpty(), isSelected = selectedMessageIds.contains(message.messageId), showIncomingSenderLabels = showIncomingSenderLabels, + simDisplayNameByParticipantId = simDisplayNameByParticipantId, onAttachmentClick = onAttachmentClick, onExternalUriClick = onExternalUriClick, onMessageClick = onMessageClick, onMessageLongClick = onMessageLongClick, onMessageResendClick = onMessageResendClick, + onSimSelectorClick = onSimSelectorClick, ) } } @@ -147,24 +169,42 @@ private fun messageAboveCurrent( return messages.getOrNull(index + 1) } +private fun messageBelowCurrent( + messages: List, + index: Int, +): ConversationMessageUiModel? { + return messages.getOrNull(index - 1) +} + @Composable private fun ConversationMessagesItem( message: ConversationMessageUiModel, messageAbove: ConversationMessageUiModel?, + messageBelow: ConversationMessageUiModel?, isSelectionMode: Boolean, isSelected: Boolean, showIncomingSenderLabels: Boolean, + simDisplayNameByParticipantId: Map, onAttachmentClick: (contentType: String, contentUri: String) -> Unit, onExternalUriClick: (String) -> Unit, onMessageClick: (String) -> Unit, onMessageLongClick: (String) -> Unit, onMessageResendClick: (String) -> Unit, + onSimSelectorClick: () -> Unit, ) { val presentation = rememberConversationMessagesItemPresentation( message = message, messageAbove = messageAbove, ) + val simDisplayName = remember(message, messageBelow, simDisplayNameByParticipantId) { + resolveConversationMessageSimDisplayName( + message = message, + messageBelow = messageBelow, + simDisplayNameByParticipantId = simDisplayNameByParticipantId, + ) + } + ColumnWithSeparator( showDateSeparator = presentation.showDateSeparator, dateSeparatorText = presentation.dateSeparatorText, @@ -177,6 +217,7 @@ private fun ConversationMessagesItem( isSelectionMode = isSelectionMode, message = message, showIncomingSenderLabel = showIncomingSenderLabels, + simDisplayName = simDisplayName, onAttachmentClick = onAttachmentClick, onExternalUriClick = onExternalUriClick, onMessageClick = { @@ -188,6 +229,7 @@ private fun ConversationMessagesItem( onMessageResendClick = { onMessageResendClick(message.messageId) }, + onSimSelectorClick = onSimSelectorClick, ) } } diff --git a/src/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessage.kt b/src/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessage.kt index a48e6570..76be2e6d 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 @@ -37,11 +37,13 @@ internal fun ConversationMessage( isSelected: Boolean = false, isSelectionMode: Boolean = false, showIncomingSenderLabel: Boolean = true, + simDisplayName: String? = null, onAttachmentClick: (contentType: String, contentUri: String) -> Unit = { _, _ -> }, onExternalUriClick: (String) -> Unit = {}, onMessageClick: () -> Unit = {}, onMessageLongClick: () -> Unit = {}, onMessageResendClick: () -> Unit = {}, + onSimSelectorClick: () -> Unit = {}, ) { BoxWithConstraints( modifier = modifier @@ -66,11 +68,13 @@ internal fun ConversationMessage( isSelectionMode = isSelectionMode, layout = layout, maxBubbleWidth = maxBubbleWidth, + simDisplayName = simDisplayName, onAttachmentClick = onAttachmentClick, onExternalUriClick = onExternalUriClick, onMessageClick = onMessageClick, onMessageLongClick = onMessageLongClick, onMessageResendClick = onMessageResendClick, + onSimSelectorClick = onSimSelectorClick, ) } } @@ -218,11 +222,13 @@ private fun ConversationMessageContent( isSelectionMode: Boolean, layout: ConversationMessageLayout, maxBubbleWidth: Dp, + simDisplayName: String?, onAttachmentClick: (contentType: String, contentUri: String) -> Unit, onExternalUriClick: (String) -> Unit, onMessageClick: () -> Unit, onMessageLongClick: () -> Unit, onMessageResendClick: () -> Unit, + onSimSelectorClick: () -> Unit, ) { Column( horizontalAlignment = messageContentHorizontalAlignment(message = message), @@ -245,6 +251,8 @@ private fun ConversationMessageContent( isSelectionMode = isSelectionMode, layout = layout, maxBubbleWidth = maxBubbleWidth, + simDisplayName = simDisplayName, + onSimSelectorClick = onSimSelectorClick, ) } } diff --git a/src/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessageMetadata.kt b/src/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessageMetadata.kt index dbc0dfd0..454a979f 100644 --- a/src/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessageMetadata.kt +++ b/src/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessageMetadata.kt @@ -5,32 +5,143 @@ import androidx.compose.foundation.layout.padding import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalResources +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.buildAnnotatedString import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.withLink import androidx.compose.ui.unit.dp +import com.android.messaging.R import com.android.messaging.ui.conversation.messages.model.message.ConversationMessageUiModel import com.android.messaging.ui.conversation.messages.model.message.ConversationMessageUiModel.Status +private const val METADATA_SEPARATOR = " • " +private const val SIM_ANNOTATION_PLACEHOLDER = "%1\$s" + @Composable internal fun ConversationMessageMetadata( message: ConversationMessageUiModel, metadataText: String?, + simDisplayName: String?, + onSimSelectorClick: () -> Unit, ) { - 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 - }, + val linkColor = MaterialTheme.colorScheme.primary + val resources = LocalResources.current + + val annotatedText = remember( + metadataText, + simDisplayName, + linkColor, + resources, + onSimSelectorClick, + ) { + buildMessageMetadataAnnotatedString( + metadataText = metadataText, + simDisplayName = simDisplayName, + simAnnotationTemplate = resources.getString( + R.string.conversation_message_sim_annotation, + ), + linkColor = linkColor, + onSimSelectorClick = onSimSelectorClick, ) } + + if (annotatedText == null) { + return + } + + Text( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 6.dp), + text = annotatedText, + style = MaterialTheme.typography.labelSmall, + color = messageMetadataColor(message = message), + textAlign = when { + message.isIncoming -> TextAlign.Start + else -> TextAlign.End + }, + ) +} + +private fun buildMessageMetadataAnnotatedString( + metadataText: String?, + simDisplayName: String?, + simAnnotationTemplate: String, + linkColor: Color, + onSimSelectorClick: () -> Unit, +): AnnotatedString? { + return when { + metadataText == null && simDisplayName == null -> null + simDisplayName == null -> AnnotatedString(text = metadataText.orEmpty()) + else -> buildSimLinkAnnotatedString( + metadataText = metadataText, + simDisplayName = simDisplayName, + simAnnotationTemplate = simAnnotationTemplate, + linkColor = linkColor, + onSimSelectorClick = onSimSelectorClick, + ) + } +} + +private fun buildSimLinkAnnotatedString( + metadataText: String?, + simDisplayName: String, + simAnnotationTemplate: String, + linkColor: Color, + onSimSelectorClick: () -> Unit, +): AnnotatedString { + val placeholderIndex = simAnnotationTemplate.indexOf(SIM_ANNOTATION_PLACEHOLDER) + + val annotationPrefix = when { + placeholderIndex >= 0 -> simAnnotationTemplate.substring(0, placeholderIndex) + else -> simAnnotationTemplate + } + + val annotationSuffix = when { + placeholderIndex >= 0 -> { + simAnnotationTemplate.substring( + placeholderIndex + SIM_ANNOTATION_PLACEHOLDER.length, + ) + } + else -> "" + } + + val link = LinkAnnotation.Clickable( + tag = SIM_LINK_TAG, + styles = TextLinkStyles( + style = SpanStyle( + color = linkColor, + textDecoration = TextDecoration.Underline, + ), + ), + ) { + onSimSelectorClick() + } + + return buildAnnotatedString { + if (!metadataText.isNullOrEmpty()) { + append(metadataText) + append(METADATA_SEPARATOR) + } + + append(annotationPrefix) + + withLink(link = link) { + append(simDisplayName) + } + + if (annotationSuffix.isNotEmpty()) { + append(annotationSuffix) + } + } } @Composable @@ -48,3 +159,5 @@ private fun messageMetadataColor( else -> MaterialTheme.colorScheme.onSurfaceVariant } } + +private const val SIM_LINK_TAG = "sim_selector" diff --git a/src/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessageRows.kt b/src/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessageRows.kt index 66252d5a..8a559c58 100644 --- a/src/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessageRows.kt +++ b/src/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessageRows.kt @@ -174,6 +174,8 @@ internal fun ConversationMessageMetadataRow( isSelectionMode: Boolean, layout: ConversationMessageLayout, maxBubbleWidth: Dp, + simDisplayName: String?, + onSimSelectorClick: () -> Unit, ) { Row( modifier = Modifier.fillMaxWidth(), @@ -200,6 +202,8 @@ internal fun ConversationMessageMetadataRow( ConversationMessageMetadata( message = message, metadataText = layout.metadataText, + simDisplayName = simDisplayName, + onSimSelectorClick = onSimSelectorClick, ) } } diff --git a/src/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessageSimAnnotation.kt b/src/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessageSimAnnotation.kt new file mode 100644 index 00000000..b2b16538 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessageSimAnnotation.kt @@ -0,0 +1,22 @@ +package com.android.messaging.ui.conversation.messages.ui.message + +import com.android.messaging.ui.conversation.messages.model.message.ConversationMessageUiModel + +internal fun resolveConversationMessageSimDisplayName( + message: ConversationMessageUiModel, + messageBelow: ConversationMessageUiModel?, + simDisplayNameByParticipantId: Map, +): String? { + val selfParticipantId = message.selfParticipantId + val displayName = selfParticipantId?.let(simDisplayNameByParticipantId::get) + + val isLastInSimRun = messageBelow == null || + messageBelow.isIncoming != message.isIncoming || + messageBelow.selfParticipantId != selfParticipantId + + return when { + simDisplayNameByParticipantId.size <= 1 -> null + !isLastInSimRun -> null + else -> displayName + } +} diff --git a/src/com/android/messaging/ui/conversation/screen/ConversationScreen.kt b/src/com/android/messaging/ui/conversation/screen/ConversationScreen.kt index 3ccc3334..e2a4af4c 100644 --- a/src/com/android/messaging/ui/conversation/screen/ConversationScreen.kt +++ b/src/com/android/messaging/ui/conversation/screen/ConversationScreen.kt @@ -185,6 +185,7 @@ internal fun ConversationScreenScaffold( onMessageClick = screenModel::onMessageClick, onMessageLongClick = screenModel::onMessageLongClick, onMessageResendClick = screenModel::onMessageResendClick, + onSimSelectorClick = simSheetState::show, ) } @@ -321,6 +322,7 @@ private fun ConversationScreenContent( onMessageClick: (String) -> Unit, onMessageLongClick: (String) -> Unit, onMessageResendClick: (String) -> Unit, + onSimSelectorClick: () -> Unit, ) { when (val messagesState = uiState.messages) { is ConversationMessagesUiState.Loading -> { @@ -366,11 +368,13 @@ private fun ConversationScreenContent( listState = messagesListState, selectedMessageIds = uiState.selection.selectedMessageIds, showIncomingSenderLabels = showIncomingSenderLabels, + subscriptions = uiState.composer.simSelector.subscriptions, onAttachmentClick = onAttachmentClick, onExternalUriClick = onExternalUriClick, onMessageClick = onMessageClick, onMessageLongClick = onMessageLongClick, onMessageResendClick = onMessageResendClick, + onSimSelectorClick = onSimSelectorClick, ) } } From 6d0dcd6b20d3d3ae5177bec3a79a0708d6b16c2d Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Mon, 11 May 2026 12:19:58 +0300 Subject: [PATCH 107/136] Use cutoff timestamp when deleting conversations --- .../model/metadata/ConversationMetadata.kt | 1 + .../repository/ConversationsRepository.kt | 8 +++++--- .../metadata/delegate/ConversationMetadataDelegate.kt | 11 ++++++++++- .../android/messaging/util/db/ext/CursorExtensions.kt | 5 +++++ 4 files changed, 21 insertions(+), 4 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 71f3f9b1..7548c3af 100644 --- a/src/com/android/messaging/data/conversation/model/metadata/ConversationMetadata.kt +++ b/src/com/android/messaging/data/conversation/model/metadata/ConversationMetadata.kt @@ -12,4 +12,5 @@ internal data class ConversationMetadata( val otherParticipantPhotoUri: String?, val isArchived: Boolean, val composerAvailability: ConversationComposerAvailability, + val sortTimestamp: Long, ) diff --git a/src/com/android/messaging/data/conversation/repository/ConversationsRepository.kt b/src/com/android/messaging/data/conversation/repository/ConversationsRepository.kt index 98fbd2ad..e691586f 100644 --- a/src/com/android/messaging/data/conversation/repository/ConversationsRepository.kt +++ b/src/com/android/messaging/data/conversation/repository/ConversationsRepository.kt @@ -22,6 +22,7 @@ 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 +import com.android.messaging.util.db.ext.getLong import com.android.messaging.util.db.ext.getStringOrEmpty import javax.inject.Inject import kotlinx.coroutines.CoroutineDispatcher @@ -60,7 +61,7 @@ internal interface ConversationsRepository { fun unarchiveConversation(conversationId: String) - fun deleteConversation(conversationId: String) + fun deleteConversation(conversationId: String, cutoffTimestamp: Long) } internal class ConversationsRepositoryImpl @Inject constructor( @@ -182,14 +183,14 @@ internal class ConversationsRepositoryImpl @Inject constructor( ?.let(UpdateConversationArchiveStatusAction::unarchiveConversation) } - override fun deleteConversation(conversationId: String) { + override fun deleteConversation(conversationId: String, cutoffTimestamp: Long) { if (conversationId.isBlank()) { return } DeleteConversationAction.deleteConversation( conversationId, - System.currentTimeMillis(), + cutoffTimestamp, ) } @@ -282,6 +283,7 @@ internal class ConversationsRepositoryImpl @Inject constructor( ?.takeIf { it.isNotBlank() }, isArchived = cursor.getInt(ConversationColumns.ARCHIVE_STATUS) == 1, composerAvailability = ConversationComposerAvailability.editable(), + sortTimestamp = cursor.getLong(ConversationColumns.SORT_TIMESTAMP), ) } } diff --git a/src/com/android/messaging/ui/conversation/metadata/delegate/ConversationMetadataDelegate.kt b/src/com/android/messaging/ui/conversation/metadata/delegate/ConversationMetadataDelegate.kt index a5164feb..a78f99d8 100644 --- a/src/com/android/messaging/ui/conversation/metadata/delegate/ConversationMetadataDelegate.kt +++ b/src/com/android/messaging/ui/conversation/metadata/delegate/ConversationMetadataDelegate.kt @@ -1,5 +1,6 @@ package com.android.messaging.ui.conversation.metadata.delegate +import com.android.messaging.data.conversation.model.metadata.ConversationMetadata import com.android.messaging.data.conversation.repository.ConversationsRepository import com.android.messaging.di.core.DefaultDispatcher import com.android.messaging.ui.conversation.common.ConversationScreenDelegate @@ -18,6 +19,7 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch internal interface ConversationMetadataDelegate : @@ -55,6 +57,7 @@ internal class ConversationMetadataDelegateImpl @Inject constructor( private var boundScope: CoroutineScope? = null private var boundConversationIdFlow: StateFlow? = null + private var latestMetadata: ConversationMetadata? = null override fun bind( scope: CoroutineScope, @@ -71,6 +74,7 @@ internal class ConversationMetadataDelegateImpl @Inject constructor( conversationIdFlow.collectLatest { conversationId -> _state.value = ConversationMetadataUiState.Loading _isDeleteConversationConfirmationVisible.value = false + latestMetadata = null if (conversationId == null) { return@collectLatest @@ -78,6 +82,7 @@ internal class ConversationMetadataDelegateImpl @Inject constructor( conversationsRepository .getConversationMetadata(conversationId = conversationId) + .onEach { metadata -> latestMetadata = metadata } .map { metadata -> when { metadata != null -> { @@ -132,11 +137,15 @@ internal class ConversationMetadataDelegateImpl @Inject constructor( override fun confirmDeleteConversation() { val conversationId = currentConversationId ?: return + val cutoffTimestamp = latestMetadata?.sortTimestamp ?: System.currentTimeMillis() _isDeleteConversationConfirmationVisible.value = false boundScope?.launch(defaultDispatcher) { - conversationsRepository.deleteConversation(conversationId = conversationId) + conversationsRepository.deleteConversation( + conversationId = conversationId, + cutoffTimestamp = cutoffTimestamp, + ) _effects.emit(ConversationScreenEffect.CloseConversation) } } diff --git a/src/com/android/messaging/util/db/ext/CursorExtensions.kt b/src/com/android/messaging/util/db/ext/CursorExtensions.kt index 5c14ede5..49bf3d6e 100644 --- a/src/com/android/messaging/util/db/ext/CursorExtensions.kt +++ b/src/com/android/messaging/util/db/ext/CursorExtensions.kt @@ -16,3 +16,8 @@ fun Cursor.getInt(columnName: String): Int { return getColumnIndexOrThrow(columnName) .let(::getInt) } + +fun Cursor.getLong(columnName: String): Long { + return getColumnIndexOrThrow(columnName) + .let(::getLong) +} From ee68016bfe0e221ed8bf70c037309b0df99a6bd0 Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Mon, 11 May 2026 18:23:31 +0300 Subject: [PATCH 108/136] Perform checks before deleting conversations --- .../delegate/ConversationMetadataDelegate.kt | 44 ++++++++++++++++++- 1 file changed, 42 insertions(+), 2 deletions(-) diff --git a/src/com/android/messaging/ui/conversation/metadata/delegate/ConversationMetadataDelegate.kt b/src/com/android/messaging/ui/conversation/metadata/delegate/ConversationMetadataDelegate.kt index a78f99d8..638eab7e 100644 --- a/src/com/android/messaging/ui/conversation/metadata/delegate/ConversationMetadataDelegate.kt +++ b/src/com/android/messaging/ui/conversation/metadata/delegate/ConversationMetadataDelegate.kt @@ -1,8 +1,11 @@ package com.android.messaging.ui.conversation.metadata.delegate +import com.android.messaging.R import com.android.messaging.data.conversation.model.metadata.ConversationMetadata import com.android.messaging.data.conversation.repository.ConversationsRepository import com.android.messaging.di.core.DefaultDispatcher +import com.android.messaging.domain.conversation.usecase.action.CheckConversationActionRequirements +import com.android.messaging.domain.conversation.usecase.action.ConversationActionRequirementsResult import com.android.messaging.ui.conversation.common.ConversationScreenDelegate import com.android.messaging.ui.conversation.metadata.mapper.ConversationMetadataUiStateMapper import com.android.messaging.ui.conversation.metadata.model.ConversationMetadataUiState @@ -36,6 +39,7 @@ internal interface ConversationMetadataDelegate : } internal class ConversationMetadataDelegateImpl @Inject constructor( + private val checkConversationActionRequirements: CheckConversationActionRequirements, private val conversationsRepository: ConversationsRepository, private val conversationMetadataUiStateMapper: ConversationMetadataUiStateMapper, @param:DefaultDispatcher @@ -130,8 +134,44 @@ internal class ConversationMetadataDelegateImpl @Inject constructor( } override fun onDeleteConversationClick() { - currentConversationId?.let { - _isDeleteConversationConfirmationVisible.value = true + if (currentConversationId == null) { + return + } + + when (checkConversationActionRequirements()) { + ConversationActionRequirementsResult.Ready -> { + _isDeleteConversationConfirmationVisible.value = true + } + + 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 -> { + emitEffect( + effect = ConversationScreenEffect.RequestDefaultSmsRole( + isSending = false, + ), + ) + } + } + } + + private fun emitEffect(effect: ConversationScreenEffect) { + boundScope?.launch(defaultDispatcher) { + _effects.emit(effect) } } From 4eb6195fd969aa731276d7a76ee9dbd41e0447e8 Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Mon, 11 May 2026 19:23:16 +0300 Subject: [PATCH 109/136] Add message segment and chars counter --- res/values/strings.xml | 20 ++++ .../model/draft/ConversationDraft.kt | 2 - .../ui/conversation/ConversationTestTags.kt | 1 + .../ConversationComposerUiStateMapper.kt | 47 +++++++- .../model/ConversationComposerUiState.kt | 3 +- .../ConversationSegmentCounterUiState.kt | 9 ++ .../composer/ui/ConversationComposeBar.kt | 113 ++++++++++++++---- .../ui/ConversationComposerSection.kt | 3 + .../conversation/screen/ConversationScreen.kt | 1 + 9 files changed, 169 insertions(+), 30 deletions(-) create mode 100644 src/com/android/messaging/ui/conversation/composer/model/ConversationSegmentCounterUiState.kt diff --git a/res/values/strings.xml b/res/values/strings.xml index 87b23f1b..68b4f4c1 100644 --- a/res/values/strings.xml +++ b/res/values/strings.xml @@ -766,6 +766,26 @@ MMS + + %1$d/%2$d + + + + %1$d character remaining in the current message segment, message will be sent as %2$d SMS messages + %1$d characters remaining in the current message segment, message will be sent as %2$d SMS messages + + + + + %1$d character remaining before the message splits into multiple SMS + %1$d characters remaining before the message splits into multiple SMS + + Undo Retry 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 f1314aea..0615d0b3 100644 --- a/src/com/android/messaging/data/conversation/model/draft/ConversationDraft.kt +++ b/src/com/android/messaging/data/conversation/model/draft/ConversationDraft.kt @@ -12,8 +12,6 @@ internal data class ConversationDraft( val attachments: ImmutableList = persistentListOf(), val isCheckingDraft: Boolean = false, val isSending: Boolean = false, - val messageCount: Int = 1, - val codePointsRemainingInCurrentMessage: Int = 0, ) { val hasContent: Boolean get() = messageText.isNotBlank() || diff --git a/src/com/android/messaging/ui/conversation/ConversationTestTags.kt b/src/com/android/messaging/ui/conversation/ConversationTestTags.kt index 2bd9fa86..235f5a8d 100644 --- a/src/com/android/messaging/ui/conversation/ConversationTestTags.kt +++ b/src/com/android/messaging/ui/conversation/ConversationTestTags.kt @@ -25,6 +25,7 @@ internal const val CONVERSATION_LOADING_INDICATOR_TEST_TAG = "conversation_loadi 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_SEGMENT_COUNTER_TEST_TAG = "conversation_segment_counter" 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/composer/mapper/ConversationComposerUiStateMapper.kt b/src/com/android/messaging/ui/conversation/composer/mapper/ConversationComposerUiStateMapper.kt index 37f42a29..a3fae371 100644 --- a/src/com/android/messaging/ui/conversation/composer/mapper/ConversationComposerUiStateMapper.kt +++ b/src/com/android/messaging/ui/conversation/composer/mapper/ConversationComposerUiStateMapper.kt @@ -1,13 +1,17 @@ package com.android.messaging.ui.conversation.composer.mapper +import com.android.messaging.data.conversation.model.draft.ConversationDraft import com.android.messaging.data.conversation.model.metadata.ConversationComposerAvailability import com.android.messaging.data.conversation.model.metadata.ConversationSubscription +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.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.ConversationSegmentCounterUiState import com.android.messaging.ui.conversation.composer.model.ConversationSimSelectorUiState import javax.inject.Inject import kotlinx.collections.immutable.ImmutableList @@ -77,14 +81,49 @@ internal class ConversationComposerUiStateMapperImpl @Inject constructor() : sendProtocol = visibleSendProtocol, attachmentCount = draft.attachments.size, pendingAttachmentCount = draftState.pendingAttachments.size, - messageCount = draft.messageCount, - codePointsRemainingInCurrentMessage = draft.codePointsRemainingInCurrentMessage, + segmentCounter = buildSegmentCounterUiState( + draft = draft, + sendProtocol = visibleSendProtocol, + ), isCheckingDraft = draft.isCheckingDraft, isSending = draft.isSending, disabledReason = composerAvailability.disabledReason, ) } + private fun buildSegmentCounterUiState( + draft: ConversationDraft, + sendProtocol: ConversationDraftSendProtocol, + ): ConversationSegmentCounterUiState? { + val isSms = sendProtocol == ConversationDraftSendProtocol.SMS + val messageText = draft.messageText + + if (!isSms || messageText.isBlank()) { + return null + } + + val stats = MessageTextStats().apply { + updateMessageTextStats(ParticipantData.DEFAULT_SELF_SUB_ID, messageText) + } + + val messageCount = stats.numMessagesToBeSent + val codePointsRemaining = stats.codePointsRemainingInCurrentMessage + + val isVisible = messageCount > 1 || + codePointsRemaining <= SEGMENT_COUNTER_VISIBILITY_THRESHOLD + + return when { + isVisible -> { + ConversationSegmentCounterUiState( + codePointsRemainingInCurrentMessage = codePointsRemaining, + messageCount = messageCount, + ) + } + + else -> null + } + } + private fun buildSimSelectorUiState( subscriptions: ImmutableList, selfParticipantId: String, @@ -98,4 +137,8 @@ internal class ConversationComposerUiStateMapperImpl @Inject constructor() : selectedSubscription = selected, ) } + + private companion object { + private const val SEGMENT_COUNTER_VISIBILITY_THRESHOLD = 10 + } } diff --git a/src/com/android/messaging/ui/conversation/composer/model/ConversationComposerUiState.kt b/src/com/android/messaging/ui/conversation/composer/model/ConversationComposerUiState.kt index add5eec1..e2981d8b 100644 --- a/src/com/android/messaging/ui/conversation/composer/model/ConversationComposerUiState.kt +++ b/src/com/android/messaging/ui/conversation/composer/model/ConversationComposerUiState.kt @@ -24,8 +24,7 @@ internal data class ConversationComposerUiState( val sendProtocol: ConversationDraftSendProtocol = ConversationDraftSendProtocol.SMS, val attachmentCount: Int = 0, val pendingAttachmentCount: Int = 0, - val messageCount: Int = 1, - val codePointsRemainingInCurrentMessage: Int = 0, + val segmentCounter: ConversationSegmentCounterUiState? = null, val isCheckingDraft: Boolean = false, val isSending: Boolean = false, val disabledReason: ConversationComposerDisabledReason? = null, diff --git a/src/com/android/messaging/ui/conversation/composer/model/ConversationSegmentCounterUiState.kt b/src/com/android/messaging/ui/conversation/composer/model/ConversationSegmentCounterUiState.kt new file mode 100644 index 00000000..615645e8 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/composer/model/ConversationSegmentCounterUiState.kt @@ -0,0 +1,9 @@ +package com.android.messaging.ui.conversation.composer.model + +import androidx.compose.runtime.Immutable + +@Immutable +internal data class ConversationSegmentCounterUiState( + val codePointsRemainingInCurrentMessage: Int, + val messageCount: Int, +) diff --git a/src/com/android/messaging/ui/conversation/composer/ui/ConversationComposeBar.kt b/src/com/android/messaging/ui/conversation/composer/ui/ConversationComposeBar.kt index 063841cf..6fd1feec 100644 --- a/src/com/android/messaging/ui/conversation/composer/ui/ConversationComposeBar.kt +++ b/src/com/android/messaging/ui/conversation/composer/ui/ConversationComposeBar.kt @@ -21,6 +21,7 @@ import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -34,14 +35,22 @@ 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.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.testTag import androidx.compose.ui.unit.dp +import com.android.messaging.R import com.android.messaging.domain.conversation.usecase.draft.model.ConversationDraftSendProtocol import com.android.messaging.ui.conversation.CONVERSATION_COMPOSE_BAR_TEST_TAG +import com.android.messaging.ui.conversation.CONVERSATION_SEGMENT_COUNTER_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.ConversationSegmentCounterUiState 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 @@ -63,6 +72,7 @@ internal fun ConversationComposeBar( messageText: String, subjectText: String, sendProtocol: ConversationDraftSendProtocol, + segmentCounter: ConversationSegmentCounterUiState?, isMessageFieldEnabled: Boolean, isAttachmentActionEnabled: Boolean, isRecordActionEnabled: Boolean, @@ -103,6 +113,7 @@ internal fun ConversationComposeBar( messageText = messageText, subjectText = subjectText, sendProtocol = sendProtocol, + segmentCounter = segmentCounter, isMessageFieldEnabled = isMessageFieldEnabled, isAttachmentActionEnabled = isAttachmentActionEnabled, isRecordActionEnabled = isRecordActionEnabled, @@ -186,6 +197,7 @@ internal fun ConversationComposeInputContent( messageText: String, subjectText: String, sendProtocol: ConversationDraftSendProtocol, + segmentCounter: ConversationSegmentCounterUiState?, isMessageFieldEnabled: Boolean, isAttachmentActionEnabled: Boolean, isRecordActionEnabled: Boolean, @@ -250,6 +262,7 @@ internal fun ConversationComposeInputContent( ConversationComposeInputSendAction( audioRecording = audioRecording, inputState = inputState, + segmentCounter = segmentCounter, onSendClick = onSendClick, onSendActionLongClick = onSendActionLongClick, onAudioRecordingStartRequest = onAudioRecordingStartRequest, @@ -265,6 +278,7 @@ private fun ConversationComposeInputSendAction( modifier: Modifier = Modifier, audioRecording: ConversationAudioRecordingUiState, inputState: ConversationComposeInputState, + segmentCounter: ConversationSegmentCounterUiState?, onSendClick: () -> Unit, onSendActionLongClick: () -> Unit, onAudioRecordingStartRequest: () -> Unit, @@ -272,31 +286,40 @@ private fun ConversationComposeInputSendAction( 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, + Column( + modifier = modifier, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + if (segmentCounter != null && !inputState.isActiveRecording) { + SegmentCounterIndicator(state = segmentCounter) + } + + 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, - ), - 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, - onSendActionLongClick = onSendActionLongClick, - ) + shouldShowLockAffordance = inputState.isActiveRecording && !audioRecording.isLocked, + lockProgress = inputState.lockProgress, + onClick = onSendClick, + onLockedStopClick = { + onAudioRecordingFinish(false) + }, + onRecordGestureStart = onAudioRecordingStartRequest, + onRecordGestureMove = onAudioRecordingDrag, + onRecordGestureLock = onAudioRecordingLock, + onRecordGestureFinish = onAudioRecordingFinish, + onSendActionLongClick = onSendActionLongClick, + ) + } } @Composable @@ -530,6 +553,48 @@ private fun ConversationComposeSendAction( } } +@Composable +private fun SegmentCounterIndicator( + state: ConversationSegmentCounterUiState, +) { + val displayText = when { + state.messageCount > 1 -> stringResource( + id = R.string.conversation_segment_counter_multi, + state.codePointsRemainingInCurrentMessage, + state.messageCount, + ) + + else -> state.codePointsRemainingInCurrentMessage.toString() + } + + val accessibilityDescription = when { + state.messageCount > 1 -> pluralStringResource( + id = R.plurals.conversation_segment_counter_content_description, + count = state.codePointsRemainingInCurrentMessage, + state.codePointsRemainingInCurrentMessage, + state.messageCount, + ) + + else -> pluralStringResource( + id = R.plurals.conversation_segment_counter_single_content_description, + count = state.codePointsRemainingInCurrentMessage, + state.codePointsRemainingInCurrentMessage, + ) + } + + Text( + modifier = Modifier + .padding(bottom = 4.dp) + .clearAndSetSemantics { + testTag = CONVERSATION_SEGMENT_COUNTER_TEST_TAG + contentDescription = accessibilityDescription + }, + text = displayText, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) +} + private data class ConversationComposeInputState( val cancelProgress: Float, val lockProgress: Float, diff --git a/src/com/android/messaging/ui/conversation/composer/ui/ConversationComposerSection.kt b/src/com/android/messaging/ui/conversation/composer/ui/ConversationComposerSection.kt index 6305dd3a..9a29c161 100644 --- a/src/com/android/messaging/ui/conversation/composer/ui/ConversationComposerSection.kt +++ b/src/com/android/messaging/ui/conversation/composer/ui/ConversationComposerSection.kt @@ -7,6 +7,7 @@ import androidx.compose.ui.focus.FocusRequester import com.android.messaging.domain.conversation.usecase.draft.model.ConversationDraftSendProtocol 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.ConversationSegmentCounterUiState import kotlinx.collections.immutable.ImmutableList @Composable @@ -17,6 +18,7 @@ internal fun ConversationComposerSection( messageText: String, subjectText: String, sendProtocol: ConversationDraftSendProtocol, + segmentCounter: ConversationSegmentCounterUiState?, isMessageFieldEnabled: Boolean, isAttachmentActionEnabled: Boolean, isRecordActionEnabled: Boolean, @@ -54,6 +56,7 @@ internal fun ConversationComposerSection( messageText = messageText, subjectText = subjectText, sendProtocol = sendProtocol, + segmentCounter = segmentCounter, isMessageFieldEnabled = isMessageFieldEnabled, isAttachmentActionEnabled = isAttachmentActionEnabled, isRecordActionEnabled = isRecordActionEnabled, diff --git a/src/com/android/messaging/ui/conversation/screen/ConversationScreen.kt b/src/com/android/messaging/ui/conversation/screen/ConversationScreen.kt index e2a4af4c..2407ff07 100644 --- a/src/com/android/messaging/ui/conversation/screen/ConversationScreen.kt +++ b/src/com/android/messaging/ui/conversation/screen/ConversationScreen.kt @@ -264,6 +264,7 @@ private fun ConversationScreenBottomBar( messageText = uiState.composer.messageText, subjectText = uiState.composer.subjectText, sendProtocol = uiState.composer.sendProtocol, + segmentCounter = uiState.composer.segmentCounter, isMessageFieldEnabled = uiState.composer.isMessageFieldEnabled, isAttachmentActionEnabled = uiState.composer.isAttachmentActionEnabled, isRecordActionEnabled = uiState.composer.isRecordActionEnabled, From 66da8234299978c69855cc634bf2d5731a059ec0 Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Tue, 12 May 2026 17:53:15 +0300 Subject: [PATCH 110/136] Group multiple phone and email destinations for same contact and format phone numbers --- .../repository/ContactsRepositoryImplTest.kt | 645 ++++++++++++++++++ .../RecipientPickerDelegateImplTest.kt | 418 ++++++++++++ .../formatter/ContactDestinationFormatter.kt | 66 ++ .../messaging/data/contact/model/Contact.kt | 13 + .../data/contact/model/ContactDestination.kt | 22 + .../data/contact/model/ContactsPage.kt | 10 + .../contact/repository/ContactsRepository.kt | 573 ++++++++++++++++ .../recipient/ConversationRecipientsPage.kt | 8 - .../ConversationRecipientsRepository.kt | 391 ----------- .../conversation/ConversationBindsModule.kt | 18 +- .../ui/conversation/ConversationTestTags.kt | 14 + .../addparticipants/AddParticipantsScreen.kt | 15 +- .../AddParticipantsViewModel.kt | 25 +- .../ui/conversation/entry/NewChatScreen.kt | 37 +- .../RecipientSelectionContactAvatar.kt | 19 +- .../RecipientSelectionContactRow.kt | 403 ++++++++++- .../RecipientSelectionContactsContent.kt | 44 +- .../RecipientSelectionContent.kt | 9 +- .../RecipientSelectionContentUiState.kt | 18 +- .../delegate/RecipientPickerDelegate.kt | 199 +++--- .../model/RecipientPickerListItem.kt | 21 +- .../android/messaging/util/PhoneUtils.java | 50 +- 22 files changed, 2429 insertions(+), 589 deletions(-) create mode 100644 app/src/test/kotlin/com/android/messaging/data/contact/repository/ContactsRepositoryImplTest.kt create mode 100644 app/src/test/kotlin/com/android/messaging/ui/conversation/recipientpicker/delegate/RecipientPickerDelegateImplTest.kt create mode 100644 src/com/android/messaging/data/contact/formatter/ContactDestinationFormatter.kt create mode 100644 src/com/android/messaging/data/contact/model/Contact.kt create mode 100644 src/com/android/messaging/data/contact/model/ContactDestination.kt create mode 100644 src/com/android/messaging/data/contact/model/ContactsPage.kt create mode 100644 src/com/android/messaging/data/contact/repository/ContactsRepository.kt delete mode 100644 src/com/android/messaging/data/conversation/model/recipient/ConversationRecipientsPage.kt delete mode 100644 src/com/android/messaging/data/conversation/repository/ConversationRecipientsRepository.kt diff --git a/app/src/test/kotlin/com/android/messaging/data/contact/repository/ContactsRepositoryImplTest.kt b/app/src/test/kotlin/com/android/messaging/data/contact/repository/ContactsRepositoryImplTest.kt new file mode 100644 index 00000000..9ed4e249 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/data/contact/repository/ContactsRepositoryImplTest.kt @@ -0,0 +1,645 @@ +package com.android.messaging.data.contact.repository + +import android.content.ContentResolver +import android.database.Cursor +import android.database.MatrixCursor +import android.net.Uri +import android.provider.ContactsContract +import com.android.messaging.data.contact.formatter.ContactDestinationFormatterImpl +import com.android.messaging.data.contact.model.Contact +import com.android.messaging.data.contact.model.ContactDestination +import com.android.messaging.sms.MmsSmsUtils +import com.android.messaging.util.PhoneUtils +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkAll +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +internal class ContactsRepositoryImplTest { + + private val contentResolver = mockk() + private val phoneUtilsInstance = mockk(relaxed = true) + + private var nextDataId = 1L + + @Before + fun setUp() { + nextDataId = 1L + mockkStatic(PhoneUtils::class) + mockkStatic(MmsSmsUtils::class) + every { PhoneUtils.getDefault() } returns phoneUtilsInstance + every { MmsSmsUtils.isEmailAddress(any()) } answers { + val raw = firstArg() + "@" in raw + } + every { phoneUtilsInstance.getCanonicalForEnteredPhoneNumber(any()) } answers { + firstArg() + } + every { phoneUtilsInstance.countryCandidatesForEnteredPhoneNumber } returns emptyList() + every { + phoneUtilsInstance.getCanonicalForEnteredPhoneNumber(any(), any>()) + } answers { + firstArg() + } + every { phoneUtilsInstance.formatForDisplay(any()) } answers { + firstArg() + } + } + + @After + fun tearDown() { + unmockkAll() + } + + @Test + fun multiNumberContactReturnsAllDestinationsWithSuperPrimaryFirst() = runTest { + stubFilterPhoneCursor( + query = "Multi", + rows = listOf( + phoneRow( + contactId = 1L, + sortKey = "Multi Person", + number = "+15550001", + type = ContactsContract.CommonDataKinds.Phone.TYPE_MOBILE, + isPrimary = false, + isSuperPrimary = false, + ), + ), + ) + stubFilterEmailCursor(query = "Multi", rows = emptyList()) + stubExpansionPhoneCursor( + rows = listOf( + phoneRow( + contactId = 1L, + sortKey = "Multi Person", + number = "+15550001", + type = ContactsContract.CommonDataKinds.Phone.TYPE_MOBILE, + ), + phoneRow( + contactId = 1L, + sortKey = "Multi Person", + number = "+15550002", + type = ContactsContract.CommonDataKinds.Phone.TYPE_HOME, + isSuperPrimary = true, + ), + phoneRow( + contactId = 1L, + sortKey = "Multi Person", + number = "+15550003", + type = ContactsContract.CommonDataKinds.Phone.TYPE_WORK, + isPrimary = true, + ), + ), + ) + stubExpansionEmailCursor(rows = emptyList()) + + val repo = createRepository() + val page = repo.searchContacts(query = "Multi", offset = 0).first() + + val contact = page.contacts.single() + Assert.assertEquals(1L, contact.id) + Assert.assertEquals(3, contact.destinations.size) + Assert.assertEquals( + listOf("+15550002", "+15550003", "+15550001"), + contact.destinations.map { it.value }, + ) + Assert.assertEquals(ContactDestination.Kind.PHONE, contact.destinations[0].kind) + Assert.assertTrue(contact.destinations[0].isSuperPrimary) + Assert.assertNull(page.nextOffset) + } + + @Test + fun nameAndNumberMatchesCollapseToSingleContactExpandedOnce() = runTest { + stubFilterPhoneCursor( + query = "Bob", + rows = listOf( + phoneRow( + contactId = 7L, + sortKey = "Bob", + number = "+17777777", + type = ContactsContract.CommonDataKinds.Phone.TYPE_MOBILE, + ), + phoneRow( + contactId = 7L, + sortKey = "Bob", + number = "+17777778", + type = ContactsContract.CommonDataKinds.Phone.TYPE_HOME, + ), + ), + ) + stubFilterEmailCursor(query = "Bob", rows = emptyList()) + stubExpansionPhoneCursor( + rows = listOf( + phoneRow( + contactId = 7L, + sortKey = "Bob", + number = "+17777777", + type = ContactsContract.CommonDataKinds.Phone.TYPE_MOBILE, + ), + phoneRow( + contactId = 7L, + sortKey = "Bob", + number = "+17777778", + type = ContactsContract.CommonDataKinds.Phone.TYPE_HOME, + ), + ), + ) + stubExpansionEmailCursor(rows = emptyList()) + + val repo = createRepository() + val page = repo.searchContacts(query = "Bob", offset = 0).first() + + val contact = page.contacts.single() + Assert.assertEquals(7L, contact.id) + Assert.assertEquals( + listOf("+17777777", "+17777778"), + contact.destinations.map { it.value }, + ) + } + + @Test + fun twoContactsSharingNumberBothKeepIt() = runTest { + stubFilterPhoneCursor( + query = "Same", + rows = listOf( + phoneRow( + contactId = 1L, + sortKey = "Alpha", + number = "+15551111", + type = ContactsContract.CommonDataKinds.Phone.TYPE_MOBILE, + ), + phoneRow( + contactId = 2L, + sortKey = "Beta", + number = "+15551111", + type = ContactsContract.CommonDataKinds.Phone.TYPE_MOBILE, + ), + ), + ) + stubFilterEmailCursor(query = "Same", rows = emptyList()) + stubExpansionPhoneCursor( + rows = listOf( + phoneRow( + contactId = 1L, + sortKey = "Alpha", + number = "+15551111", + type = ContactsContract.CommonDataKinds.Phone.TYPE_MOBILE, + ), + phoneRow( + contactId = 2L, + sortKey = "Beta", + number = "+15551111", + type = ContactsContract.CommonDataKinds.Phone.TYPE_MOBILE, + ), + ), + ) + stubExpansionEmailCursor(rows = emptyList()) + + val repo = createRepository() + val page = repo.searchContacts(query = "Same", offset = 0).first() + + Assert.assertEquals(listOf(1L, 2L), page.contacts.map(Contact::id)) + page.contacts.forEach { contact -> + Assert.assertEquals("+15551111", contact.destinations.single().value) + } + } + + @Test + fun digitFallbackRecoversContactWhenFilterMatchesNothing() = runTest { + stubFilterPhoneCursor(query = "1234", rows = emptyList()) + stubFilterEmailCursor(query = "1234", rows = emptyList()) + stubDefaultPhoneCursor( + rows = listOf( + phoneRow( + contactId = 3L, + sortKey = "Charlie", + number = "+1 (555) 1234567", + type = ContactsContract.CommonDataKinds.Phone.TYPE_MOBILE, + ), + ), + ) + stubExpansionPhoneCursor( + rows = listOf( + phoneRow( + contactId = 3L, + sortKey = "Charlie", + number = "+1 (555) 1234567", + type = ContactsContract.CommonDataKinds.Phone.TYPE_MOBILE, + ), + ), + ) + stubExpansionEmailCursor(rows = emptyList()) + + val repo = createRepository() + val page = repo.searchContacts(query = "1234", offset = 0).first() + + Assert.assertEquals(3L, page.contacts.single().id) + } + + @Test + fun paginationSplitsContactsAcrossPages() = runTest { + val rows = (1..250).map { id -> + phoneRow( + contactId = id.toLong(), + sortKey = "Person %03d".format(id), + number = "+1555%07d".format(id), + type = ContactsContract.CommonDataKinds.Phone.TYPE_MOBILE, + ) + } + stubDefaultPhoneCursor(rows = rows) + stubExpansionPhoneCursor( + rows = rows, + ) + stubExpansionEmailCursor( + rows = emptyList(), + ) + + val repo = createRepository() + val firstPage = repo.searchContacts(query = "", offset = 0).first() + val secondPage = repo.searchContacts(query = "", offset = 200).first() + + Assert.assertEquals(200, firstPage.contacts.size) + Assert.assertEquals(200, firstPage.nextOffset) + Assert.assertEquals(50, secondPage.contacts.size) + Assert.assertNull(secondPage.nextOffset) + } + + @Test + fun phoneAndEmailDestinationsForSameContactMerge() = runTest { + stubFilterPhoneCursor( + query = "Dee", + rows = listOf( + phoneRow( + contactId = 4L, + sortKey = "Dee", + number = "+14444444", + type = ContactsContract.CommonDataKinds.Phone.TYPE_MOBILE, + ), + ), + ) + stubFilterEmailCursor(query = "Dee", rows = emptyList()) + stubExpansionPhoneCursor( + rows = listOf( + phoneRow( + contactId = 4L, + sortKey = "Dee", + number = "+14444444", + type = ContactsContract.CommonDataKinds.Phone.TYPE_MOBILE, + ), + ), + ) + stubExpansionEmailCursor( + rows = listOf( + emailRow( + contactId = 4L, + sortKey = "Dee", + address = "dee@example.com", + type = ContactsContract.CommonDataKinds.Email.TYPE_WORK, + ), + ), + ) + + val repo = createRepository() + val page = repo.searchContacts(query = "Dee", offset = 0).first() + + val contact = page.contacts.single() + Assert.assertEquals(2, contact.destinations.size) + val kinds = contact.destinations.map { it.kind } + Assert.assertEquals(ContactDestination.Kind.PHONE, kinds[0]) + Assert.assertEquals(ContactDestination.Kind.EMAIL, kinds[1]) + } + + @Test + fun largeContactSetGetsChunkedAndMergedEquivalently() = runTest { + val contactCount = 1100 + val ids = (1..contactCount).map { it.toLong() } + val rows = ids.map { id -> + phoneRow( + contactId = id, + sortKey = "Person %05d".format(id), + number = "+1555%07d".format(id), + type = ContactsContract.CommonDataKinds.Phone.TYPE_MOBILE, + ) + } + stubFilterPhoneCursor(query = "Person", rows = rows) + stubFilterEmailCursor(query = "Person", rows = emptyList()) + stubExpansionPhoneCursor(rows = rows) + stubExpansionEmailCursor(rows = emptyList()) + + val repo = createRepository() + val page = repo.searchContacts(query = "Person", offset = 0).first() + + Assert.assertEquals(200, page.contacts.size) + Assert.assertEquals(200, page.nextOffset) + val firstContact = page.contacts.first() + Assert.assertFalse(firstContact.destinations.isEmpty()) + } + + @Test + fun searchExpandsOnlyPageContactsNotAllMatches() = runTest { + val totalMatches = 600 + val ids = (1..totalMatches).map { it.toLong() } + val rows = ids.map { id -> + phoneRow( + contactId = id, + sortKey = "Person %05d".format(id), + number = "+1555%07d".format(id), + type = ContactsContract.CommonDataKinds.Phone.TYPE_MOBILE, + ) + } + stubFilterPhoneCursor(query = "Person", rows = rows) + stubFilterEmailCursor(query = "Person", rows = emptyList()) + + val queriedPhoneContactIds = mutableSetOf() + val queriedEmailContactIds = mutableSetOf() + + every { + contentResolver.query( + match { uri -> uri == ContactsContract.CommonDataKinds.Phone.CONTENT_URI }, + any(), + match { selection -> + selection.startsWith(ContactsContract.CommonDataKinds.Phone.CONTACT_ID) + }, + any(), + isNull(), + ) + } answers { + val selectionArgs = arg?>(3) ?: emptyArray() + selectionArgs.forEach { queriedPhoneContactIds.add(it.toLong()) } + val argSet = selectionArgs.toSet() + val matchingRows = rows.filter { row -> row.contactId.toString() in argSet } + phoneCursor(rows = matchingRows) + } + + every { + contentResolver.query( + match { uri -> uri == ContactsContract.CommonDataKinds.Email.CONTENT_URI }, + any(), + match { selection -> + selection.startsWith(ContactsContract.CommonDataKinds.Email.CONTACT_ID) + }, + any(), + isNull(), + ) + } answers { + val selectionArgs = arg?>(3) ?: emptyArray() + selectionArgs.forEach { queriedEmailContactIds.add(it.toLong()) } + emailCursor(rows = emptyList()) + } + + val repo = createRepository() + val page = repo.searchContacts(query = "Person", offset = 0).first() + + Assert.assertEquals(200, page.contacts.size) + Assert.assertEquals(200, page.nextOffset) + Assert.assertEquals((1L..200L).toSet(), queriedPhoneContactIds) + Assert.assertEquals((1L..200L).toSet(), queriedEmailContactIds) + } + + private fun createRepository(): ContactsRepositoryImpl { + return ContactsRepositoryImpl( + formatter = ContactDestinationFormatterImpl(), + contentResolver = contentResolver, + ioDispatcher = UnconfinedTestDispatcher(), + ) + } + + private fun stubFilterPhoneCursor(query: String, rows: List) { + every { + contentResolver.query( + match { uri -> isPhoneFilterUri(uri = uri, query = query) }, + any(), + isNull(), + isNull(), + any(), + ) + } answers { phoneCursor(rows = rows) } + } + + private fun stubFilterEmailCursor(query: String, rows: List) { + every { + contentResolver.query( + match { uri -> isEmailFilterUri(uri = uri, query = query) }, + any(), + isNull(), + isNull(), + any(), + ) + } answers { emailCursor(rows = rows) } + } + + private fun stubDefaultPhoneCursor(rows: List) { + every { + contentResolver.query( + match { uri -> isDefaultPhoneUri(uri = uri) }, + any(), + isNull(), + isNull(), + any(), + ) + } answers { phoneCursor(rows = rows) } + } + + private fun stubExpansionPhoneCursor(rows: List) { + every { + contentResolver.query( + match { uri -> uri == ContactsContract.CommonDataKinds.Phone.CONTENT_URI }, + any(), + match { selection -> + selection.startsWith(ContactsContract.CommonDataKinds.Phone.CONTACT_ID) + }, + any(), + isNull(), + ) + } answers { + val selectionArgs = (arg?>(3) ?: emptyArray()).toSet() + val matchingRows = rows.filter { row -> + row.contactId.toString() in selectionArgs + } + phoneCursor(rows = matchingRows) + } + } + + private fun stubExpansionEmailCursor(rows: List) { + every { + contentResolver.query( + match { uri -> uri == ContactsContract.CommonDataKinds.Email.CONTENT_URI }, + any(), + match { selection -> + selection.startsWith(ContactsContract.CommonDataKinds.Email.CONTACT_ID) + }, + any(), + isNull(), + ) + } answers { + val selectionArgs = (arg?>(3) ?: emptyArray()).toSet() + val matchingRows = rows.filter { row -> + row.contactId.toString() in selectionArgs + } + emailCursor(rows = matchingRows) + } + } + + private fun isPhoneFilterUri(uri: Uri, query: String): Boolean { + val expectedPrefix = ContactsContract.CommonDataKinds.Phone.CONTENT_FILTER_URI.toString() + return uri.toString().startsWith(expectedPrefix) && + uri.pathSegments.contains(query) + } + + private fun isEmailFilterUri(uri: Uri, query: String): Boolean { + val expectedPrefix = ContactsContract.CommonDataKinds.Email.CONTENT_FILTER_URI.toString() + return uri.toString().startsWith(expectedPrefix) && + uri.pathSegments.contains(query) + } + + private fun isDefaultPhoneUri(uri: Uri): Boolean { + return uri.path == ContactsContract.CommonDataKinds.Phone.CONTENT_URI.path && + uri.getQueryParameter("directory") != null + } + + private fun phoneCursor(rows: List): Cursor { + val cursor = MatrixCursor( + arrayOf( + ContactsContract.CommonDataKinds.Phone._ID, + ContactsContract.CommonDataKinds.Phone.CONTACT_ID, + ContactsContract.CommonDataKinds.Phone.LOOKUP_KEY, + ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME_PRIMARY, + ContactsContract.CommonDataKinds.Phone.SORT_KEY_PRIMARY, + ContactsContract.CommonDataKinds.Phone.PHOTO_THUMBNAIL_URI, + ContactsContract.CommonDataKinds.Phone.NUMBER, + ContactsContract.CommonDataKinds.Phone.TYPE, + ContactsContract.CommonDataKinds.Phone.LABEL, + ContactsContract.CommonDataKinds.Phone.IS_PRIMARY, + ContactsContract.CommonDataKinds.Phone.IS_SUPER_PRIMARY, + ), + ) + rows.forEach { row -> + cursor.addRow( + arrayOf( + row.dataId, + row.contactId, + row.lookupKey, + row.displayName, + row.sortKey, + row.photoUri, + row.value, + row.type, + row.customLabel, + if (row.isPrimary) 1 else 0, + if (row.isSuperPrimary) 1 else 0, + ), + ) + } + return cursor + } + + private fun emailCursor(rows: List): Cursor { + val cursor = MatrixCursor( + arrayOf( + ContactsContract.CommonDataKinds.Email._ID, + ContactsContract.CommonDataKinds.Email.CONTACT_ID, + ContactsContract.CommonDataKinds.Email.LOOKUP_KEY, + ContactsContract.CommonDataKinds.Email.DISPLAY_NAME_PRIMARY, + ContactsContract.CommonDataKinds.Email.SORT_KEY_PRIMARY, + ContactsContract.CommonDataKinds.Email.PHOTO_THUMBNAIL_URI, + ContactsContract.CommonDataKinds.Email.ADDRESS, + ContactsContract.CommonDataKinds.Email.TYPE, + ContactsContract.CommonDataKinds.Email.LABEL, + ContactsContract.CommonDataKinds.Email.IS_PRIMARY, + ContactsContract.CommonDataKinds.Email.IS_SUPER_PRIMARY, + ), + ) + rows.forEach { row -> + cursor.addRow( + arrayOf( + row.dataId, + row.contactId, + row.lookupKey, + row.displayName, + row.sortKey, + row.photoUri, + row.value, + row.type, + row.customLabel, + if (row.isPrimary) 1 else 0, + if (row.isSuperPrimary) 1 else 0, + ), + ) + } + return cursor + } + + private fun phoneRow( + contactId: Long, + sortKey: String, + number: String, + type: Int, + customLabel: String? = null, + isPrimary: Boolean = false, + isSuperPrimary: Boolean = false, + dataId: Long = nextDataId++, + ): RawRow { + return RawRow( + dataId = dataId, + contactId = contactId, + lookupKey = "lookup_$contactId", + displayName = sortKey, + sortKey = sortKey, + photoUri = null, + value = number, + type = type, + customLabel = customLabel, + isPrimary = isPrimary, + isSuperPrimary = isSuperPrimary, + ) + } + + private fun emailRow( + contactId: Long, + sortKey: String, + address: String, + type: Int, + customLabel: String? = null, + dataId: Long = nextDataId++, + ): RawRow { + return RawRow( + dataId = dataId, + contactId = contactId, + lookupKey = "lookup_$contactId", + displayName = sortKey, + sortKey = sortKey, + photoUri = null, + value = address, + type = type, + customLabel = customLabel, + isPrimary = false, + isSuperPrimary = false, + ) + } + + private data class RawRow( + val dataId: Long, + val contactId: Long, + val lookupKey: String, + val displayName: String, + val sortKey: String, + val photoUri: String?, + val value: String, + val type: Int, + val customLabel: String?, + val isPrimary: Boolean, + val isSuperPrimary: Boolean, + ) +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/recipientpicker/delegate/RecipientPickerDelegateImplTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/recipientpicker/delegate/RecipientPickerDelegateImplTest.kt new file mode 100644 index 00000000..92102142 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/recipientpicker/delegate/RecipientPickerDelegateImplTest.kt @@ -0,0 +1,418 @@ +package com.android.messaging.ui.conversation.recipientpicker.delegate + +import androidx.lifecycle.SavedStateHandle +import com.android.messaging.data.contact.formatter.ContactDestinationFormatterImpl +import com.android.messaging.data.contact.model.Contact +import com.android.messaging.data.contact.model.ContactDestination +import com.android.messaging.data.contact.model.ContactsPage +import com.android.messaging.data.contact.repository.ContactsRepository +import com.android.messaging.domain.contacts.usecase.IsReadContactsPermissionGranted +import com.android.messaging.sms.MmsSmsUtils +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 io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkAll +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +internal class RecipientPickerDelegateImplTest { + + private val phoneUtilsInstance = mockk(relaxed = true) + + @Before + fun setUp() { + mockkStatic(PhoneUtils::class) + mockkStatic(MmsSmsUtils::class) + every { PhoneUtils.getDefault() } returns phoneUtilsInstance + every { PhoneUtils.isValidSmsMmsDestination(any()) } answers { + val raw = firstArg() + raw.isNotBlank() && raw.any { character -> character.isDigit() } + } + every { MmsSmsUtils.isEmailAddress(any()) } answers { + val raw = firstArg() + "@" in raw + } + every { phoneUtilsInstance.getCanonicalForEnteredPhoneNumber(any()) } answers { + firstArg() + } + } + + @After + fun tearDown() { + unmockkAll() + } + + @Test + fun emptyQueryAndEmptyRepoYieldsEmptyItems() = runTest { + val delegate = createDelegate( + initialQuery = "", + pages = mapOf(searchKey(query = "", offset = 0) to emptyPage()), + ) + + val finalState = bindAndAwait(delegate = delegate) + + assertTrue(finalState.items.isEmpty()) + assertFalse(finalState.canLoadMore) + assertTrue(finalState.hasContactsPermission) + assertFalse(finalState.isLoading) + } + + @Test + fun searchReturnsMultiDestinationContact() = runTest { + val multiDestContact = contact( + id = 11L, + displayName = "Multi Dest", + destinations = listOf( + destination(value = "+15550001", contactId = 11L), + destination(value = "+15550002", contactId = 11L), + destination(value = "multi@example.com", contactId = 11L, isEmail = true), + ), + ) + val delegate = createDelegate( + initialQuery = "multi", + pages = mapOf( + searchKey(query = "multi", offset = 0) to pageOf(multiDestContact), + ), + ) + + val finalState = bindAndAwait(delegate = delegate) + + val contactItem = finalState.items + .filterIsInstance() + .single() + assertEquals(11L, contactItem.contact.id) + assertEquals(3, contactItem.destinations.size) + assertEquals( + listOf("+15550001", "+15550002", "multi@example.com"), + contactItem.destinations.map { it.value }, + ) + } + + @Test + fun excludedDestinationRemovesMatchingDestinationButKeepsContact() = runTest { + every { + phoneUtilsInstance.getCanonicalForEnteredPhoneNumber("(555) 000-0001") + } returns "+15550001" + val multiDestContact = contact( + id = 12L, + displayName = "Excluded Sample", + destinations = listOf( + destination( + value = "+15550001", + contactId = 12L, + normalizedValue = "+15550001", + ), + destination( + value = "+15550002", + contactId = 12L, + normalizedValue = "+15550002", + ), + ), + ) + val delegate = createDelegate( + initialQuery = "", + pages = mapOf(searchKey(query = "", offset = 0) to pageOf(multiDestContact)), + ) + delegate.onExcludedDestinationsChanged(destinations = setOf("(555) 000-0001")) + + val finalState = bindAndAwait(delegate = delegate) + + val contactItem = finalState.items + .filterIsInstance() + .single() + assertEquals(1, contactItem.destinations.size) + assertEquals("+15550002", contactItem.destinations.single().value) + } + + @Test + fun excludedDestinationCanonicalizesIncomingValues() = runTest { + every { + phoneUtilsInstance.getCanonicalForEnteredPhoneNumber("+1 555-000-0001") + } returns "+15550001" + val singleDestContact = contact( + id = 13L, + displayName = "Cross Format", + destinations = listOf( + destination( + value = "(555) 000-0001", + contactId = 13L, + normalizedValue = "+15550001", + ), + ), + ) + val delegate = createDelegate( + initialQuery = "", + pages = mapOf(searchKey(query = "", offset = 0) to pageOf(singleDestContact)), + ) + delegate.onExcludedDestinationsChanged(destinations = setOf("+1 555-000-0001")) + + val finalState = bindAndAwait(delegate = delegate) + + val contactItems = finalState.items + .filterIsInstance() + assertTrue(contactItems.isEmpty()) + } + + @Test + fun excludingAllDestinationsTriggersFallbackToNextPage() = runTest { + val firstPageContact = contact( + id = 21L, + displayName = "All Excluded", + destinations = listOf( + destination(value = "+15550001", contactId = 21L), + destination(value = "+15550002", contactId = 21L), + ), + ) + val secondPageContact = contact( + id = 22L, + displayName = "Has Free Destination", + destinations = listOf(destination(value = "+15550003", contactId = 22L)), + ) + val delegate = createDelegate( + initialQuery = "", + pages = mapOf( + searchKey(query = "", offset = 0) to ContactsPage( + contacts = persistentListOf(firstPageContact), + nextOffset = 1, + ), + searchKey(query = "", offset = 1) to ContactsPage( + contacts = persistentListOf(secondPageContact), + nextOffset = null, + ), + ), + ) + delegate.onExcludedDestinationsChanged( + destinations = setOf("+15550001", "+15550002"), + ) + + val finalState = bindAndAwait(delegate = delegate) + + val contactItem = finalState.items + .filterIsInstance() + .single() + assertEquals(22L, contactItem.contact.id) + assertFalse(finalState.canLoadMore) + } + + @Test + fun loadMoreAppendsNextPage() = runTest { + val firstContact = contact( + id = 31L, + displayName = "Alpha", + destinations = listOf(destination(value = "+11111111", contactId = 31L)), + ) + val secondContact = contact( + id = 32L, + displayName = "Beta", + destinations = listOf(destination(value = "+22222222", contactId = 32L)), + ) + val delegate = createDelegate( + initialQuery = "", + pages = mapOf( + searchKey(query = "", offset = 0) to ContactsPage( + contacts = persistentListOf(firstContact), + nextOffset = 1, + ), + searchKey(query = "", offset = 1) to ContactsPage( + contacts = persistentListOf(secondContact), + nextOffset = null, + ), + ), + ) + + bindAndAwait(delegate = delegate) + delegate.onLoadMore() + testScheduler.advanceTimeBy(delayTimeMillis = 1_000L) + testScheduler.runCurrent() + + val finalState = delegate.state.value + val contactItems = finalState.items.filterIsInstance() + assertEquals(listOf(31L, 32L), contactItems.map { it.contact.id }) + assertFalse(finalState.canLoadMore) + } + + @Test + fun missingContactsPermissionEmitsEmptyState() = runTest { + val delegate = createDelegate( + initialQuery = "", + pages = emptyMap(), + isPermissionGranted = false, + ) + + val finalState = bindAndAwait(delegate = delegate) + + assertTrue(finalState.items.isEmpty()) + assertFalse(finalState.hasContactsPermission) + assertFalse(finalState.canLoadMore) + } + + @Test + fun syntheticPhoneAppearsWhenQueryHasDigitsAndNoMatchingDestination() = runTest { + val contactWithDifferentNumber = contact( + id = 41L, + displayName = "Some Person", + destinations = listOf(destination(value = "+19999999", contactId = 41L)), + ) + val delegate = createDelegate( + initialQuery = "5550001", + pages = mapOf( + searchKey(query = "5550001", offset = 0) to pageOf(contactWithDifferentNumber), + ), + ) + + val finalState = bindAndAwait(delegate = delegate) + + val syntheticItem = finalState.items + .filterIsInstance() + .single() + assertEquals("5550001", syntheticItem.destination) + assertEquals(2, finalState.items.size) + } + + @Test + fun syntheticPhoneSuppressedWhenContactHasMatchingDestination() = runTest { + val contactWithMatchingNumber = contact( + id = 51L, + displayName = "Match", + destinations = listOf(destination(value = "5550001", contactId = 51L)), + ) + val delegate = createDelegate( + initialQuery = "5550001", + pages = mapOf( + searchKey(query = "5550001", offset = 0) to pageOf(contactWithMatchingNumber), + ), + ) + + val finalState = bindAndAwait(delegate = delegate) + + val syntheticItems = finalState.items + .filterIsInstance() + assertTrue(syntheticItems.isEmpty()) + assertEquals(1, finalState.items.size) + } + + private fun TestScope.bindAndAwait( + delegate: RecipientPickerDelegateImpl, + ): RecipientPickerUiState { + delegate.bind(scope = backgroundScope) + testScheduler.advanceTimeBy(delayTimeMillis = 1_000L) + testScheduler.runCurrent() + return delegate.state.value + } + + private fun TestScope.createDelegate( + initialQuery: String, + pages: Map, + isPermissionGranted: Boolean = true, + ): RecipientPickerDelegateImpl { + return RecipientPickerDelegateImpl( + contactDestinationFormatter = ContactDestinationFormatterImpl(), + contactsRepository = FakeRepository(pages = pages), + isReadContactsPermissionGranted = FakePermission(granted = isPermissionGranted), + savedStateHandle = SavedStateHandle( + initialState = mapOf("search_query" to initialQuery), + ), + defaultDispatcher = UnconfinedTestDispatcher(scheduler = testScheduler), + ) + } + + private fun searchKey(query: String, offset: Int): SearchKey { + return SearchKey(query = query, offset = offset) + } + + private fun emptyPage(): ContactsPage { + return ContactsPage( + contacts = persistentListOf(), + nextOffset = null, + ) + } + + private fun pageOf(vararg contacts: Contact): ContactsPage { + return ContactsPage( + contacts = contacts.toList().toImmutableList(), + nextOffset = null, + ) + } + + private fun contact( + id: Long, + displayName: String, + destinations: List, + ): Contact { + return Contact( + id = id, + lookupKey = "lookup_$id", + displayName = displayName, + photoUri = null, + destinations = destinations.toImmutableList(), + ) + } + + private fun destination( + value: String, + contactId: Long, + isEmail: Boolean = false, + normalizedValue: String = value, + displayValue: String = value, + ): ContactDestination { + return ContactDestination( + dataId = contactId * 100 + value.hashCode().toLong(), + contactId = contactId, + value = value, + normalizedValue = normalizedValue, + displayValue = displayValue, + kind = when { + isEmail -> ContactDestination.Kind.EMAIL + else -> ContactDestination.Kind.PHONE + }, + type = 1, + customLabel = null, + isPrimary = false, + isSuperPrimary = false, + ) + } + + private data class SearchKey( + val query: String, + val offset: Int, + ) + + private class FakeRepository( + private val pages: Map, + ) : ContactsRepository { + + override fun searchContacts( + query: String, + offset: Int, + ): Flow { + val key = SearchKey(query = query, offset = offset) + val page = pages[key] ?: ContactsPage( + contacts = persistentListOf(), + nextOffset = null, + ) + return flowOf(page) + } + } + + private class FakePermission( + private val granted: Boolean, + ) : IsReadContactsPermissionGranted { + override fun invoke(): Boolean = granted + } +} diff --git a/src/com/android/messaging/data/contact/formatter/ContactDestinationFormatter.kt b/src/com/android/messaging/data/contact/formatter/ContactDestinationFormatter.kt new file mode 100644 index 00000000..32bdde43 --- /dev/null +++ b/src/com/android/messaging/data/contact/formatter/ContactDestinationFormatter.kt @@ -0,0 +1,66 @@ +package com.android.messaging.data.contact.formatter + +import com.android.messaging.sms.MmsSmsUtils +import com.android.messaging.util.PhoneUtils +import java.util.Locale +import javax.inject.Inject + +internal interface ContactDestinationFormatter { + + fun canonicalize(value: String): String + + fun canonicalize(value: String, countryCandidates: List): String + + fun formatPhoneForDisplay(value: String): String + + fun countryCandidates(): List +} + +internal class ContactDestinationFormatterImpl @Inject constructor() : ContactDestinationFormatter { + + override fun canonicalize(value: String): String { + return canonicalize(value = value) { trimmed -> + PhoneUtils + .getDefault() + .getCanonicalForEnteredPhoneNumber(trimmed) + } + } + + override fun canonicalize( + value: String, + countryCandidates: List, + ): String { + return canonicalize(value = value) { trimmed -> + PhoneUtils + .getDefault() + .getCanonicalForEnteredPhoneNumber(trimmed, countryCandidates) + } + } + + override fun formatPhoneForDisplay(value: String): String { + return PhoneUtils.getDefault().formatForDisplay(value) + } + + override fun countryCandidates(): List { + val phoneUtils = PhoneUtils + .getDefault() + .apply { + warmUp() + } + + return phoneUtils.countryCandidatesForEnteredPhoneNumber + } + + private inline fun canonicalize( + value: String, + canonicalizePhoneNumber: (trimmed: String) -> String, + ): String { + val trimmed = value.trim() + + return when { + trimmed.isEmpty() -> trimmed + MmsSmsUtils.isEmailAddress(trimmed) -> trimmed.lowercase(Locale.ROOT) + else -> canonicalizePhoneNumber(trimmed) + } + } +} diff --git a/src/com/android/messaging/data/contact/model/Contact.kt b/src/com/android/messaging/data/contact/model/Contact.kt new file mode 100644 index 00000000..a1d7aae1 --- /dev/null +++ b/src/com/android/messaging/data/contact/model/Contact.kt @@ -0,0 +1,13 @@ +package com.android.messaging.data.contact.model + +import androidx.compose.runtime.Immutable +import kotlinx.collections.immutable.ImmutableList + +@Immutable +internal data class Contact( + val id: Long, + val lookupKey: String, + val displayName: String, + val photoUri: String?, + val destinations: ImmutableList, +) diff --git a/src/com/android/messaging/data/contact/model/ContactDestination.kt b/src/com/android/messaging/data/contact/model/ContactDestination.kt new file mode 100644 index 00000000..edc37cf9 --- /dev/null +++ b/src/com/android/messaging/data/contact/model/ContactDestination.kt @@ -0,0 +1,22 @@ +package com.android.messaging.data.contact.model + +import androidx.compose.runtime.Immutable + +@Immutable +internal data class ContactDestination( + val dataId: Long, + val contactId: Long, + val value: String, + val normalizedValue: String, + val displayValue: String, + val kind: Kind, + val type: Int, + val customLabel: String?, + val isPrimary: Boolean, + val isSuperPrimary: Boolean, +) { + enum class Kind { + PHONE, + EMAIL, + } +} diff --git a/src/com/android/messaging/data/contact/model/ContactsPage.kt b/src/com/android/messaging/data/contact/model/ContactsPage.kt new file mode 100644 index 00000000..ad58a165 --- /dev/null +++ b/src/com/android/messaging/data/contact/model/ContactsPage.kt @@ -0,0 +1,10 @@ +package com.android.messaging.data.contact.model + +import androidx.compose.runtime.Immutable +import kotlinx.collections.immutable.ImmutableList + +@Immutable +internal data class ContactsPage( + val contacts: ImmutableList, + val nextOffset: Int?, +) diff --git a/src/com/android/messaging/data/contact/repository/ContactsRepository.kt b/src/com/android/messaging/data/contact/repository/ContactsRepository.kt new file mode 100644 index 00000000..938eb153 --- /dev/null +++ b/src/com/android/messaging/data/contact/repository/ContactsRepository.kt @@ -0,0 +1,573 @@ +package com.android.messaging.data.contact.repository + +import android.content.ContentResolver +import android.database.Cursor +import android.net.Uri +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.contact.formatter.ContactDestinationFormatter +import com.android.messaging.data.contact.model.Contact +import com.android.messaging.data.contact.model.ContactDestination +import com.android.messaging.data.contact.model.ContactsPage +import com.android.messaging.di.core.IoDispatcher +import com.android.messaging.util.core.extension.typedFlow +import javax.inject.Inject +import kotlinx.collections.immutable.PersistentList +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 ContactsRepository { + + fun searchContacts( + query: String, + offset: Int, + ): Flow +} + +internal class ContactsRepositoryImpl @Inject constructor( + private val formatter: ContactDestinationFormatter, + private val contentResolver: ContentResolver, + @param:IoDispatcher + private val ioDispatcher: CoroutineDispatcher, +) : ContactsRepository { + + override fun searchContacts( + query: String, + offset: Int, + ): Flow { + return typedFlow { + queryContactsPage( + query = query, + offset = offset, + ) + }.flowOn(ioDispatcher) + } + + private fun queryContactsPage( + query: String, + offset: Int, + ): ContactsPage { + return when { + query.isBlank() -> queryAllContactsByDefaultPhoneOrder(offset = offset) + else -> queryMatchedContacts(query = query, offset = offset) + } + } + + private fun queryAllContactsByDefaultPhoneOrder(offset: Int): ContactsPage { + val phoneRows = readDestinationRows( + uri = createDefaultPhoneQueryUri(), + kind = ContactDestination.Kind.PHONE, + sortOrder = SORT_BY_SORT_KEY_PRIMARY_ASC, + ) + + val contactGroups = when { + phoneRows.isEmpty() -> emptyList() + else -> groupRowsByContactPreservingOrder(rows = phoneRows) + } + + return paginateAndBuildContacts( + contactGroups = contactGroups, + offset = offset, + ) + } + + private fun queryMatchedContacts(query: String, offset: Int): ContactsPage { + val phoneMatches = readDestinationRows( + uri = createPhoneFilterUri(query = query), + kind = ContactDestination.Kind.PHONE, + sortOrder = SORT_BY_SORT_KEY_PRIMARY_ASC, + ) + + val emailMatches = readDestinationRows( + uri = createEmailFilterUri(query = query), + kind = ContactDestination.Kind.EMAIL, + sortOrder = SORT_BY_SORT_KEY_PRIMARY_ASC, + ) + + val mergedMatches = (phoneMatches + emailMatches).sortedBy { it.sortKey } + + if (mergedMatches.isEmpty()) { + val hasDigits = query.any { character -> character.isDigit() } + + return when { + hasDigits -> queryDigitsFallback(query = query, offset = offset) + else -> emptyContactsPage() + } + } + + return paginateMatchedContactIds(matchRows = mergedMatches, offset = offset) + } + + private fun queryDigitsFallback(query: String, offset: Int): ContactsPage { + val queryDigits = extractDigits(value = query) + + val phoneRows = readDestinationRows( + uri = createDefaultPhoneQueryUri(), + kind = ContactDestination.Kind.PHONE, + sortOrder = SORT_BY_SORT_KEY_PRIMARY_ASC, + ) + + val matchingRows = phoneRows.filter { row -> + extractDigits(value = row.value).contains(queryDigits) + } + + return when { + matchingRows.isEmpty() -> emptyContactsPage() + else -> paginateMatchedContactIds(matchRows = matchingRows, offset = offset) + } + } + + private fun paginateMatchedContactIds( + matchRows: List, + offset: Int, + ): ContactsPage { + val contactSortKeys = LinkedHashMap() + + matchRows.forEach { row -> + contactSortKeys.putIfAbsent(row.contactId, row.sortKey) + } + + val orderedContactIds = contactSortKeys.keys.toList() + + val pageStart = offset.coerceAtMost(maximumValue = orderedContactIds.size) + val pageEndExclusive = (pageStart + PAGE_SIZE) + .coerceAtMost(maximumValue = orderedContactIds.size) + + val pageContactIds = orderedContactIds.subList( + fromIndex = pageStart, + toIndex = pageEndExclusive, + ) + + val pageGroups = when { + pageContactIds.isEmpty() -> emptyList() + else -> { + loadAndGroupDestinationsForContacts( + contactIds = pageContactIds, + sortKeysByContactId = contactSortKeys, + ) + } + } + + val pagedContacts = when { + pageGroups.isEmpty() -> persistentListOf() + else -> buildPageContacts(pageGroups = pageGroups) + } + + val nextOffset = pageEndExclusive.takeIf { it < orderedContactIds.size } + + return ContactsPage( + contacts = pagedContacts, + nextOffset = nextOffset, + ) + } + + private fun loadAndGroupDestinationsForContacts( + contactIds: List, + sortKeysByContactId: Map, + ): List> { + val phoneRows = readDestinationRowsForContacts( + uri = Phone.CONTENT_URI, + kind = ContactDestination.Kind.PHONE, + contactIds = contactIds, + ) + + val emailRows = readDestinationRowsForContacts( + uri = Email.CONTENT_URI, + kind = ContactDestination.Kind.EMAIL, + contactIds = contactIds, + ) + + val rowsByContact = LinkedHashMap>() + contactIds.forEach { contactId -> + rowsByContact[contactId] = mutableListOf() + } + + (phoneRows + emailRows).forEach { row -> + rowsByContact[row.contactId]?.add(row) + } + + return rowsByContact.entries + .asSequence() + .filter { (_, rows) -> rows.isNotEmpty() } + .sortedWith( + compareBy>> { + sortKeysByContactId[it.key].orEmpty() + } + .thenBy { it.value.first().displayName } + .thenBy { it.key }, + ) + .map { it.value } + .toList() + } + + private fun emptyContactsPage(): ContactsPage { + return ContactsPage( + contacts = persistentListOf(), + nextOffset = null, + ) + } + + private fun groupRowsByContactPreservingOrder( + rows: List, + ): List> { + val rowsByContact = LinkedHashMap>() + rows.forEach { row -> + rowsByContact.getOrPut(row.contactId) { mutableListOf() }.add(row) + } + + return rowsByContact.values.toList() + } + + private fun readDestinationRowsForContacts( + uri: Uri, + kind: ContactDestination.Kind, + contactIds: Collection, + ): List { + if (contactIds.isEmpty()) { + return emptyList() + } + + val rows = mutableListOf() + + contactIds.chunked(size = CONTACT_ID_CHUNK_SIZE).forEach { chunk -> + val placeholders = chunk.joinToString(separator = ",") { "?" } + val selection = "${Phone.CONTACT_ID} IN ($placeholders)" + val selectionArgs = chunk.map { it.toString() }.toTypedArray() + + rows.addAll( + readDestinationRows( + uri = uri, + kind = kind, + selection = selection, + selectionArgs = selectionArgs, + sortOrder = null, + ), + ) + } + + return rows + } + + private fun readDestinationRows( + uri: Uri, + kind: ContactDestination.Kind, + selection: String? = null, + selectionArgs: Array? = null, + sortOrder: String?, + ): List { + val projection = when (kind) { + ContactDestination.Kind.PHONE -> phoneProjection + ContactDestination.Kind.EMAIL -> emailProjection + } + + val destinationColumnName = when (kind) { + ContactDestination.Kind.PHONE -> Phone.NUMBER + ContactDestination.Kind.EMAIL -> Email.ADDRESS + } + + return contentResolver + .query( + uri, + projection, + selection, + selectionArgs, + sortOrder, + ) + ?.use { cursor -> + mapDestinationRows( + cursor = cursor, + kind = kind, + destinationColumnName = destinationColumnName, + ) + } + ?: emptyList() + } + + private fun mapDestinationRows( + cursor: Cursor, + kind: ContactDestination.Kind, + destinationColumnName: String, + ): List { + val columns = DestinationCursorColumns( + dataIdIndex = cursor.getColumnIndexOrThrow(Phone._ID), + contactIdIndex = cursor.getColumnIndexOrThrow(Phone.CONTACT_ID), + destinationIndex = cursor.getColumnIndexOrThrow(destinationColumnName), + displayNameIndex = cursor.getColumnIndexOrThrow(Phone.DISPLAY_NAME_PRIMARY), + sortKeyIndex = cursor.getColumnIndexOrThrow(Phone.SORT_KEY_PRIMARY), + photoUriIndex = cursor.getColumnIndexOrThrow(Phone.PHOTO_THUMBNAIL_URI), + lookupKeyIndex = cursor.getColumnIndexOrThrow(Phone.LOOKUP_KEY), + typeIndex = cursor.getColumnIndexOrThrow(Phone.TYPE), + labelIndex = cursor.getColumnIndexOrThrow(Phone.LABEL), + isPrimaryIndex = cursor.getColumnIndexOrThrow(Phone.IS_PRIMARY), + isSuperPrimaryIndex = cursor.getColumnIndexOrThrow(Phone.IS_SUPER_PRIMARY), + ) + + val rows = mutableListOf() + + while (cursor.moveToNext()) { + mapDestinationRow( + cursor = cursor, + kind = kind, + columns = columns, + )?.let(rows::add) + } + + return rows + } + + private fun mapDestinationRow( + cursor: Cursor, + kind: ContactDestination.Kind, + columns: DestinationCursorColumns, + ): DestinationRow? { + val value = cursor + .getString(columns.destinationIndex) + ?.trim() + .orEmpty() + + val contactId = cursor.getLong(columns.contactIdIndex) + + val isInvalidRow = value.isBlank() || contactId <= 0L + + if (isInvalidRow) { + return null + } + + val displayName = cursor + .getString(columns.displayNameIndex) + ?.trim() + .orEmpty() + .ifBlank { value } + + val photoUri = cursor + .getString(columns.photoUriIndex) + ?.takeIf { it.isNotBlank() } + + return DestinationRow( + dataId = cursor.getLong(columns.dataIdIndex), + contactId = contactId, + lookupKey = cursor.getString(columns.lookupKeyIndex).orEmpty(), + displayName = displayName, + sortKey = cursor.getString(columns.sortKeyIndex)?.trim().orEmpty(), + photoUri = photoUri, + kind = kind, + value = value, + type = cursor.getInt(columns.typeIndex), + customLabel = cursor.getString(columns.labelIndex)?.takeIf { it.isNotBlank() }, + isPrimary = cursor.getInt(columns.isPrimaryIndex) != 0, + isSuperPrimary = cursor.getInt(columns.isSuperPrimaryIndex) != 0, + ) + } + + private fun buildContact( + rows: List, + canonicalize: (String) -> String, + formatPhoneForDisplay: (String) -> String, + ): Contact { + val first = rows.first() + val orderedRows = rows.sortedWith( + compareByDescending { it.isSuperPrimary } + .thenByDescending { it.isPrimary } + .thenBy { it.kind.ordinal } + .thenBy { it.dataId }, + ) + + val destinations = persistentListOf().builder() + val seenNormalizedValues = LinkedHashSet() + orderedRows.forEach { row -> + val normalizedValue = canonicalize(row.value) + if (seenNormalizedValues.add(normalizedValue)) { + val displayValue = when (row.kind) { + ContactDestination.Kind.EMAIL -> row.value + ContactDestination.Kind.PHONE -> formatPhoneForDisplay(row.value) + } + + destinations.add( + ContactDestination( + dataId = row.dataId, + contactId = row.contactId, + value = row.value, + normalizedValue = normalizedValue, + displayValue = displayValue, + kind = row.kind, + type = row.type, + customLabel = row.customLabel, + isPrimary = row.isPrimary, + isSuperPrimary = row.isSuperPrimary, + ), + ) + } + } + + return Contact( + id = first.contactId, + lookupKey = first.lookupKey, + displayName = first.displayName, + photoUri = first.photoUri, + destinations = destinations.build(), + ) + } + + private fun paginateAndBuildContacts( + contactGroups: List>, + offset: Int, + ): ContactsPage { + val pageStart = offset.coerceAtMost(maximumValue = contactGroups.size) + val pageEndExclusive = (pageStart + PAGE_SIZE) + .coerceAtMost(maximumValue = contactGroups.size) + + val pageGroups = contactGroups.subList(fromIndex = pageStart, toIndex = pageEndExclusive) + + val pagedContacts = when { + pageGroups.isEmpty() -> persistentListOf() + else -> buildPageContacts(pageGroups = pageGroups) + } + + val nextOffset = pageEndExclusive.takeIf { it < contactGroups.size } + + return ContactsPage( + contacts = pagedContacts, + nextOffset = nextOffset, + ) + } + + private fun buildPageContacts( + pageGroups: List>, + ): PersistentList { + val countryCandidates = formatter.countryCandidates() + val canonicalCache = HashMap() + val displayCache = HashMap() + + val canonicalize: (String) -> String = { value -> + canonicalCache.getOrPut(value) { + formatter.canonicalize( + value = value, + countryCandidates = countryCandidates, + ) + } + } + + val formatPhoneForDisplay: (String) -> String = { value -> + displayCache.getOrPut(value) { formatter.formatPhoneForDisplay(value) } + } + + return pageGroups + .map { rows -> + buildContact( + rows = rows, + canonicalize = canonicalize, + formatPhoneForDisplay = formatPhoneForDisplay, + ) + } + .toPersistentList() + } + + private fun createDefaultPhoneQueryUri(): Uri { + return Phone.CONTENT_URI + .buildUpon() + .appendQueryParameter( + ContactsContract.DIRECTORY_PARAM_KEY, + Directory.DEFAULT.toString(), + ) + .build() + } + + private fun createPhoneFilterUri(query: String): Uri { + return Phone.CONTENT_FILTER_URI + .buildUpon() + .appendPath(query) + .appendQueryParameter( + ContactsContract.DIRECTORY_PARAM_KEY, + Directory.DEFAULT.toString(), + ) + .build() + } + + private fun createEmailFilterUri(query: String): Uri { + return Email.CONTENT_FILTER_URI + .buildUpon() + .appendPath(query) + .appendQueryParameter( + ContactsContract.DIRECTORY_PARAM_KEY, + Directory.DEFAULT.toString(), + ) + .build() + } + + private fun extractDigits(value: String): String { + return value.filter { character -> character.isDigit() } + } + + private data class DestinationRow( + val dataId: Long, + val contactId: Long, + val lookupKey: String, + val displayName: String, + val sortKey: String, + val photoUri: String?, + val kind: ContactDestination.Kind, + val value: String, + val type: Int, + val customLabel: String?, + val isPrimary: Boolean, + val isSuperPrimary: Boolean, + ) + + private data class DestinationCursorColumns( + val dataIdIndex: Int, + val contactIdIndex: Int, + val destinationIndex: Int, + val displayNameIndex: Int, + val sortKeyIndex: Int, + val photoUriIndex: Int, + val lookupKeyIndex: Int, + val typeIndex: Int, + val labelIndex: Int, + val isPrimaryIndex: Int, + val isSuperPrimaryIndex: Int, + ) + + private companion object { + private const val PAGE_SIZE = 200 + private const val CONTACT_ID_CHUNK_SIZE = 500 + + private const val SORT_BY_SORT_KEY_PRIMARY_ASC = "${Phone.SORT_KEY_PRIMARY} ASC" + + private val phoneProjection by lazy { + arrayOf( + Phone._ID, + Phone.CONTACT_ID, + Phone.LOOKUP_KEY, + Phone.DISPLAY_NAME_PRIMARY, + Phone.SORT_KEY_PRIMARY, + Phone.PHOTO_THUMBNAIL_URI, + Phone.NUMBER, + Phone.TYPE, + Phone.LABEL, + Phone.IS_PRIMARY, + Phone.IS_SUPER_PRIMARY, + ) + } + + private val emailProjection by lazy { + arrayOf( + Email._ID, + Email.CONTACT_ID, + Email.LOOKUP_KEY, + Email.DISPLAY_NAME_PRIMARY, + Email.SORT_KEY_PRIMARY, + Email.PHOTO_THUMBNAIL_URI, + Email.ADDRESS, + Email.TYPE, + Email.LABEL, + Email.IS_PRIMARY, + Email.IS_SUPER_PRIMARY, + ) + } + } +} diff --git a/src/com/android/messaging/data/conversation/model/recipient/ConversationRecipientsPage.kt b/src/com/android/messaging/data/conversation/model/recipient/ConversationRecipientsPage.kt deleted file mode 100644 index 7a84291b..00000000 --- a/src/com/android/messaging/data/conversation/model/recipient/ConversationRecipientsPage.kt +++ /dev/null @@ -1,8 +0,0 @@ -package com.android.messaging.data.conversation.model.recipient - -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 deleted file mode 100644 index 348a5b6c..00000000 --- a/src/com/android/messaging/data/conversation/repository/ConversationRecipientsRepository.kt +++ /dev/null @@ -1,391 +0,0 @@ -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.data.conversation.model.recipient.ConversationRecipientsPage -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, - ) - - if (recipientEntry == null || !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 { - val pageStart = offset.coerceAtMost(maximumValue = recipients.size) - val pageEndExclusive = (pageStart + PAGE_SIZE).coerceAtMost(maximumValue = recipients.size) - - val pagedRecipients = recipients - .subList( - fromIndex = pageStart, - toIndex = pageEndExclusive, - ) - .map { it.recipient } - .toPersistentList() - - val nextOffset = pageEndExclusive.takeIf { nextOffset -> - nextOffset < recipients.size - } - - return ConversationRecipientsPage( - recipients = pagedRecipients, - nextOffset = nextOffset, - ) - } - - 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 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 67a474b1..0282724e 100644 --- a/src/com/android/messaging/di/conversation/ConversationBindsModule.kt +++ b/src/com/android/messaging/di/conversation/ConversationBindsModule.kt @@ -1,5 +1,9 @@ package com.android.messaging.di.conversation +import com.android.messaging.data.contact.formatter.ContactDestinationFormatter +import com.android.messaging.data.contact.formatter.ContactDestinationFormatterImpl +import com.android.messaging.data.contact.repository.ContactsRepository +import com.android.messaging.data.contact.repository.ContactsRepositoryImpl import com.android.messaging.data.conversation.mapper.ConversationDraftMessageDataMapper import com.android.messaging.data.conversation.mapper.ConversationDraftMessageDataMapperImpl import com.android.messaging.data.conversation.mapper.ConversationMessageDataDraftMapper @@ -10,8 +14,6 @@ import com.android.messaging.data.conversation.repository.ConversationDraftsRepo import com.android.messaging.data.conversation.repository.ConversationDraftsRepositoryImpl 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.ConversationSubscriptionsRepository import com.android.messaging.data.conversation.repository.ConversationSubscriptionsRepositoryImpl import com.android.messaging.data.conversation.repository.ConversationVCardMetadataRepository @@ -102,9 +104,9 @@ internal abstract class ConversationBindsModule { @Binds @Reusable - abstract fun bindConversationRecipientsRepository( - impl: ConversationRecipientsRepositoryImpl, - ): ConversationRecipientsRepository + abstract fun bindConversationContactsRepository( + impl: ContactsRepositoryImpl, + ): ContactsRepository @Binds @Reusable @@ -112,6 +114,12 @@ internal abstract class ConversationBindsModule { impl: CanAddMoreConversationParticipantsImpl, ): CanAddMoreConversationParticipants + @Binds + @Reusable + abstract fun bindContactDestinationFormatter( + impl: ContactDestinationFormatterImpl, + ): ContactDestinationFormatter + @Binds @Reusable abstract fun bindCheckConversationActionRequirements( diff --git a/src/com/android/messaging/ui/conversation/ConversationTestTags.kt b/src/com/android/messaging/ui/conversation/ConversationTestTags.kt index 235f5a8d..5f3b88b8 100644 --- a/src/com/android/messaging/ui/conversation/ConversationTestTags.kt +++ b/src/com/android/messaging/ui/conversation/ConversationTestTags.kt @@ -78,10 +78,24 @@ internal fun newChatContactRowTestTag(contactId: String): String { return "new_chat_contact_row_$contactId" } +internal fun newChatContactDestinationRowTestTag( + contactId: String, + destination: String, +): String { + return "new_chat_contact_destination_row_${contactId}_$destination" +} + internal fun addParticipantsContactRowTestTag(contactId: String): String { return "add_participants_contact_row_$contactId" } +internal fun addParticipantsContactDestinationRowTestTag( + contactId: String, + destination: String, +): String { + return "add_participants_contact_destination_row_${contactId}_$destination" +} + internal val conversationShapeSemanticsKey = SemanticsPropertyKey( name = "conversation_shape", ) diff --git a/src/com/android/messaging/ui/conversation/addparticipants/AddParticipantsScreen.kt b/src/com/android/messaging/ui/conversation/addparticipants/AddParticipantsScreen.kt index e974cf17..9e2c9929 100644 --- a/src/com/android/messaging/ui/conversation/addparticipants/AddParticipantsScreen.kt +++ b/src/com/android/messaging/ui/conversation/addparticipants/AddParticipantsScreen.kt @@ -25,6 +25,7 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.android.messaging.R import com.android.messaging.ui.conversation.ADD_PARTICIPANTS_CONFIRM_BUTTON_TEST_TAG +import com.android.messaging.ui.conversation.addParticipantsContactDestinationRowTestTag 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 @@ -143,12 +144,18 @@ private fun AddParticipantsRecipientSelectionContent( queryPlaceholderText = stringResource(id = R.string.new_chat_query_hint), ), rowDecorators = RecipientSelectionRowDecorators( - recipientRowTestTag = { contact -> - addParticipantsContactRowTestTag(contactId = contact.id) + recipientRowTestTag = { item -> + addParticipantsContactRowTestTag(contactId = item.id) + }, + destinationRowTestTag = { item, destination -> + addParticipantsContactDestinationRowTestTag( + contactId = item.id, + destination = destination, + ) }, ), - onRecipientClick = { contact -> - onRecipientClick(contact.destination) + onRecipientDestinationClick = { _, destination -> + onRecipientClick(destination) }, modifier = modifier, onLoadMore = onLoadMore, diff --git a/src/com/android/messaging/ui/conversation/addparticipants/AddParticipantsViewModel.kt b/src/com/android/messaging/ui/conversation/addparticipants/AddParticipantsViewModel.kt index d2de79f1..8396d0df 100644 --- a/src/com/android/messaging/ui/conversation/addparticipants/AddParticipantsViewModel.kt +++ b/src/com/android/messaging/ui/conversation/addparticipants/AddParticipantsViewModel.kt @@ -4,6 +4,7 @@ import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.android.messaging.R +import com.android.messaging.data.contact.formatter.ContactDestinationFormatter import com.android.messaging.data.conversation.model.recipient.ConversationRecipient import com.android.messaging.data.conversation.repository.ConversationParticipantsRepository import com.android.messaging.di.core.MainDispatcher @@ -16,9 +17,12 @@ import com.android.messaging.ui.conversation.recipientpicker.delegate.RecipientP import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.ImmutableSet import kotlinx.collections.immutable.PersistentList import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.persistentSetOf import kotlinx.collections.immutable.toImmutableList +import kotlinx.collections.immutable.toImmutableSet import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow @@ -44,6 +48,7 @@ internal interface AddParticipantsModel { @HiltViewModel internal class AddParticipantsViewModel @Inject constructor( + private val contactDestinationFormatter: ContactDestinationFormatter, private val conversationParticipantsRepository: ConversationParticipantsRepository, private val isConversationRecipientLimitExceeded: IsConversationRecipientLimitExceeded, private val recipientPickerDelegate: RecipientPickerDelegate, @@ -105,6 +110,7 @@ internal class AddParticipantsViewModel @Inject constructor( updateLocalUiState( localUiState.value.copy( existingParticipants = persistentEmptyParticipants(), + existingParticipantCanonicalDestinations = persistentSetOf(), isLoadingConversationParticipants = conversationId != null, isResolvingConversation = false, selectedRecipientDestinations = persistentEmptyDestinations(), @@ -121,22 +127,30 @@ internal class AddParticipantsViewModel @Inject constructor( conversationParticipantsRepository .getParticipants(conversationId = conversationId) .collect { participants -> + val canonicalDestinations = participants + .map { participant -> + contactDestinationFormatter.canonicalize( + value = participant.destination, + ) + } + .toImmutableSet() + val selectedDestinations = localUiState.value .selectedRecipientDestinations .filterNot { selectedDestination -> - participants.any { participant -> - participant.destination == selectedDestination - } + selectedDestination in canonicalDestinations } .toImmutableList() updateLocalUiState( localUiState.value.copy( existingParticipants = participants, + existingParticipantCanonicalDestinations = canonicalDestinations, isLoadingConversationParticipants = false, selectedRecipientDestinations = selectedDestinations, ), ) + recipientPickerDelegate.onExcludedDestinationsChanged( destinations = participants .map { participant -> @@ -170,9 +184,7 @@ internal class AddParticipantsViewModel @Inject constructor( val shouldIgnoreRecipientClick = trimmedDestination.isEmpty() || currentUiState.isLoadingConversationParticipants || currentUiState.isResolvingConversation || - currentUiState.existingParticipants.any { participant -> - participant.destination == trimmedDestination - } + trimmedDestination in currentUiState.existingParticipantCanonicalDestinations if (shouldIgnoreRecipientClick) { return @@ -275,6 +287,7 @@ internal class AddParticipantsViewModel @Inject constructor( private data class LocalAddParticipantsUiState( val existingParticipants: ImmutableList = persistentListOf(), + val existingParticipantCanonicalDestinations: ImmutableSet = persistentSetOf(), val isLoadingConversationParticipants: Boolean = true, val isResolvingConversation: Boolean = false, val selectedRecipientDestinations: ImmutableList = persistentListOf(), diff --git a/src/com/android/messaging/ui/conversation/entry/NewChatScreen.kt b/src/com/android/messaging/ui/conversation/entry/NewChatScreen.kt index 4b08c3db..d7bfc1ce 100644 --- a/src/com/android/messaging/ui/conversation/entry/NewChatScreen.kt +++ b/src/com/android/messaging/ui/conversation/entry/NewChatScreen.kt @@ -50,6 +50,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.android.messaging.R 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.newChatContactDestinationRowTestTag import com.android.messaging.ui.conversation.newChatContactRowTestTag import com.android.messaging.ui.conversation.recipientpicker.RecipientPickerModel import com.android.messaging.ui.conversation.recipientpicker.RecipientPickerViewModel @@ -161,40 +162,36 @@ private fun NewChatRecipientSelectionContent( queryPlaceholderText = stringResource(id = R.string.new_chat_query_hint), ), rowDecorators = RecipientSelectionRowDecorators( - recipientRowTestTag = { contact -> - newChatContactRowTestTag(contactId = contact.id) + recipientRowTestTag = { item -> + newChatContactRowTestTag(contactId = item.id) }, - showRecipientTrailingIndicator = { contact -> + destinationRowTestTag = { item, destination -> + newChatContactDestinationRowTestTag( + contactId = item.id, + destination = destination, + ) + }, + showRecipientTrailingIndicator = { _, destination -> !isCreatingGroup && isResolvingConversationIndicatorVisible && - resolvingRecipientDestination == contact.destination + resolvingRecipientDestination == destination }, trailingIndicatorTestTag = NEW_CHAT_CONTACT_RESOLVING_INDICATOR_TEST_TAG, ), - onRecipientClick = { contact -> + onRecipientDestinationClick = { _, destination -> when { - isCreatingGroup -> { - onCreateGroupRecipientClick(contact.destination) - } - - else -> { - onContactClick(contact.destination) - } + isCreatingGroup -> onCreateGroupRecipientClick(destination) + else -> onContactClick(destination) } }, modifier = modifier, onLoadMore = onLoadMore, onPrimaryActionClick = onCreateGroupConfirmed, onQueryChanged = onQueryChanged, - onRecipientLongClick = { contact -> + onRecipientDestinationLongClick = { _, destination -> when { - isCreatingGroup -> { - onCreateGroupRecipientClick(contact.destination) - } - - else -> { - onContactLongClick(contact.destination) - } + isCreatingGroup -> onCreateGroupRecipientClick(destination) + else -> onContactLongClick(destination) } }, topListContent = { diff --git a/src/com/android/messaging/ui/conversation/recipientpicker/RecipientSelectionContactAvatar.kt b/src/com/android/messaging/ui/conversation/recipientpicker/RecipientSelectionContactAvatar.kt index 9772e71c..8aeefaa0 100644 --- a/src/com/android/messaging/ui/conversation/recipientpicker/RecipientSelectionContactAvatar.kt +++ b/src/com/android/messaging/ui/conversation/recipientpicker/RecipientSelectionContactAvatar.kt @@ -126,10 +126,11 @@ private fun RecipientSelectionTextAvatar( modifier: Modifier = Modifier, ) { val displayName = recipientSelectionItemDisplayName(item = item) - val label = remember(displayName, item.destination) { + val avatarSourceText = recipientSelectionAvatarSourceText(item = item) + val label = remember(displayName, avatarSourceText) { recipientSelectionAvatarLabel( displayName = displayName, - destination = item.destination, + destination = avatarSourceText, ) } @@ -155,7 +156,7 @@ internal fun recipientSelectionItemDisplayName( item: RecipientPickerListItem, ): String { return when (item) { - is RecipientPickerListItem.Contact -> item.recipient.displayName + is RecipientPickerListItem.Contact -> item.contact.displayName is RecipientPickerListItem.SyntheticPhone -> { stringResource( id = R.string.contact_list_send_to_text, @@ -167,11 +168,21 @@ internal fun recipientSelectionItemDisplayName( private fun recipientSelectionPhotoUri(item: RecipientPickerListItem): String? { return when (item) { - is RecipientPickerListItem.Contact -> item.recipient.photoUri + is RecipientPickerListItem.Contact -> item.contact.photoUri is RecipientPickerListItem.SyntheticPhone -> null } } +private fun recipientSelectionAvatarSourceText(item: RecipientPickerListItem): String { + return when (item) { + is RecipientPickerListItem.Contact -> { + item.contact.destinations.firstOrNull()?.value.orEmpty() + } + + is RecipientPickerListItem.SyntheticPhone -> item.destination + } +} + private fun recipientSelectionAvatarLabel( displayName: String, destination: String, diff --git a/src/com/android/messaging/ui/conversation/recipientpicker/RecipientSelectionContactRow.kt b/src/com/android/messaging/ui/conversation/recipientpicker/RecipientSelectionContactRow.kt index a6a7d6b1..a701114e 100644 --- a/src/com/android/messaging/ui/conversation/recipientpicker/RecipientSelectionContactRow.kt +++ b/src/com/android/messaging/ui/conversation/recipientpicker/RecipientSelectionContactRow.kt @@ -1,5 +1,7 @@ package com.android.messaging.ui.conversation.recipientpicker +import android.provider.ContactsContract.CommonDataKinds.Email +import android.provider.ContactsContract.CommonDataKinds.Phone import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.animateColor import androidx.compose.animation.core.FastOutSlowInEasing @@ -28,20 +30,31 @@ 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.graphics.Color import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.platform.testTag import androidx.compose.ui.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.data.contact.model.ContactDestination import com.android.messaging.ui.conversation.recipientpicker.model.RecipientPickerListItem +import kotlinx.collections.immutable.ImmutableSet private val contactCornerRadius = 18.dp private val contactMiddleCornerRadius = 2.dp +private val avatarSize = 40.dp +private val avatarToTextSpacing = 14.dp +private val rowHorizontalPadding = 16.dp +private val rowVerticalPadding = 14.dp +private val destinationIndentation = avatarSize + avatarToTextSpacing +private val destinationVerticalPadding = 10.dp + private val topContactShape = RoundedCornerShape( topStart = contactCornerRadius, topEnd = contactCornerRadius, @@ -61,14 +74,104 @@ private val singleContactShape = RoundedCornerShape(size = contactCornerRadius) internal fun RecipientSelectionContactRow( item: RecipientPickerListItem, enabled: Boolean, + selectedDestinations: ImmutableSet, + onDestinationClick: (destination: String) -> Unit, + shape: RoundedCornerShape, + rowDecorators: RecipientSelectionRowDecorators, + modifier: Modifier = Modifier, + onDestinationLongClick: ((destination: String) -> Unit)? = null, +) { + when (item) { + is RecipientPickerListItem.Contact -> { + ContactRow( + modifier = modifier, + item = item, + enabled = enabled, + selectedDestinations = selectedDestinations, + onDestinationClick = onDestinationClick, + onDestinationLongClick = onDestinationLongClick, + shape = shape, + rowDecorators = rowDecorators, + ) + } + + is RecipientPickerListItem.SyntheticPhone -> { + SyntheticPhoneRow( + modifier = modifier, + item = item, + enabled = enabled, + isSelected = selectedDestinations.contains(item.normalizedDestination), + onClick = { onDestinationClick(item.normalizedDestination) }, + onLongClick = onDestinationLongClick?.let { callback -> + { callback(item.normalizedDestination) } + }, + shape = shape, + rowDecorators = rowDecorators, + ) + } + } +} + +@Composable +private fun ContactRow( + item: RecipientPickerListItem.Contact, + enabled: Boolean, + selectedDestinations: ImmutableSet, + onDestinationClick: (destination: String) -> Unit, + onDestinationLongClick: ((destination: String) -> Unit)?, + shape: RoundedCornerShape, + rowDecorators: RecipientSelectionRowDecorators, + modifier: Modifier = Modifier, +) { + val destinations = item.destinations + val isSingleDestination = destinations.size <= 1 + val singleDestination = destinations.firstOrNull() + val isSingleSelected = singleDestination != null && + selectedDestinations.contains(singleDestination.normalizedValue) + + when { + isSingleDestination && singleDestination != null -> { + SingleDestinationContactRow( + modifier = modifier, + item = item, + destination = singleDestination, + enabled = enabled, + isSelected = isSingleSelected, + onClick = { onDestinationClick(singleDestination.normalizedValue) }, + onLongClick = onDestinationLongClick?.let { callback -> + { callback(singleDestination.normalizedValue) } + }, + shape = shape, + rowDecorators = rowDecorators, + ) + } + + else -> { + MultiDestinationContactRow( + modifier = modifier, + item = item, + enabled = enabled, + selectedDestinations = selectedDestinations, + onDestinationClick = onDestinationClick, + onDestinationLongClick = onDestinationLongClick, + shape = shape, + rowDecorators = rowDecorators, + ) + } + } +} + +@Composable +private fun SingleDestinationContactRow( + item: RecipientPickerListItem.Contact, + destination: ContactDestination, + enabled: Boolean, isSelected: Boolean, onClick: () -> Unit, + onLongClick: (() -> Unit)?, shape: RoundedCornerShape, - rowTestTag: String, + rowDecorators: RecipientSelectionRowDecorators, modifier: Modifier = Modifier, - onLongClick: (() -> Unit)? = null, - showTrailingIndicator: Boolean = false, - trailingIndicatorTestTag: String? = null, ) { val hapticFeedback = LocalHapticFeedback.current val selectionTransition = updateTransition( @@ -84,14 +187,9 @@ internal fun RecipientSelectionContactRow( modifier = Modifier .then(other = modifier) .fillMaxWidth() - .testTag(rowTestTag) - .semantics { - selected = isSelected - } - .background( - color = containerColor, - shape = shape, - ) + .testTag(rowDecorators.recipientRowTestTag(item)) + .semantics { selected = isSelected } + .background(color = containerColor, shape = shape) .combinedClickable( enabled = enabled, onClick = { @@ -105,7 +203,7 @@ internal fun RecipientSelectionContactRow( } }, ) - .padding(horizontal = 16.dp, vertical = 14.dp), + .padding(horizontal = rowHorizontalPadding, vertical = rowVerticalPadding), verticalAlignment = Alignment.CenterVertically, ) { RecipientSelectionContactAvatar( @@ -113,40 +211,303 @@ internal fun RecipientSelectionContactRow( isSelected = isSelected, ) - RecipientSelectionContactText( + ContactPrimaryAndSecondaryText( + primaryText = item.contact.displayName, + secondaryText = destination.displayValue, + primaryTextColor = primaryTextColor, + secondaryTextColor = secondaryTextColor, + ) + + RecipientSelectionTrailingIndicator( + visible = rowDecorators.showRecipientTrailingIndicator( + item, + destination.normalizedValue, + ), + testTag = rowDecorators.trailingIndicatorTestTag, + ) + } +} + +@Composable +private fun SyntheticPhoneRow( + item: RecipientPickerListItem.SyntheticPhone, + enabled: Boolean, + isSelected: Boolean, + onClick: () -> Unit, + onLongClick: (() -> Unit)?, + shape: RoundedCornerShape, + rowDecorators: RecipientSelectionRowDecorators, + modifier: Modifier = Modifier, +) { + 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(rowDecorators.recipientRowTestTag(item)) + .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 = rowHorizontalPadding, vertical = rowVerticalPadding), + verticalAlignment = Alignment.CenterVertically, + ) { + RecipientSelectionContactAvatar( item = item, + isSelected = isSelected, + ) + + ContactPrimaryAndSecondaryText( + primaryText = recipientSelectionItemDisplayName(item = item), + secondaryText = item.secondaryText, primaryTextColor = primaryTextColor, secondaryTextColor = secondaryTextColor, ) RecipientSelectionTrailingIndicator( - visible = showTrailingIndicator, - testTag = trailingIndicatorTestTag, + visible = rowDecorators.showRecipientTrailingIndicator( + item, + item.normalizedDestination, + ), + testTag = rowDecorators.trailingIndicatorTestTag, ) } } @Composable -private fun RowScope.RecipientSelectionContactText( - item: RecipientPickerListItem, +private fun MultiDestinationContactRow( + item: RecipientPickerListItem.Contact, + enabled: Boolean, + selectedDestinations: ImmutableSet, + onDestinationClick: (destination: String) -> Unit, + onDestinationLongClick: ((destination: String) -> Unit)?, + shape: RoundedCornerShape, + rowDecorators: RecipientSelectionRowDecorators, + modifier: Modifier = Modifier, +) { + Column( + modifier = Modifier + .then(other = modifier) + .fillMaxWidth() + .testTag(rowDecorators.recipientRowTestTag(item)) + .background( + color = MaterialTheme.colorScheme.background, + shape = shape, + ) + .padding( + horizontal = rowHorizontalPadding, + vertical = rowVerticalPadding, + ), + ) { + MultiDestinationContactHeader(item = item) + + Column( + modifier = Modifier.padding(top = 8.dp), + verticalArrangement = Arrangement.spacedBy(space = 4.dp), + ) { + item.destinations.forEach { destination -> + MultiDestinationMiniRow( + item = item, + destination = destination, + enabled = enabled, + isSelected = selectedDestinations.contains(destination.normalizedValue), + onClick = { onDestinationClick(destination.normalizedValue) }, + onLongClick = onDestinationLongClick?.let { callback -> + { callback(destination.normalizedValue) } + }, + rowDecorators = rowDecorators, + ) + } + } + } +} + +@Composable +private fun MultiDestinationContactHeader( + item: RecipientPickerListItem.Contact, +) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + RecipientSelectionContactAvatar( + item = item, + isSelected = false, + ) + + Text( + modifier = Modifier + .padding(start = avatarToTextSpacing) + .weight(weight = 1f), + text = item.contact.displayName, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + } +} + +@Composable +private fun MultiDestinationMiniRow( + item: RecipientPickerListItem.Contact, + destination: ContactDestination, + enabled: Boolean, + isSelected: Boolean, + onClick: () -> Unit, + onLongClick: (() -> Unit)?, + rowDecorators: RecipientSelectionRowDecorators, +) { + val hapticFeedback = LocalHapticFeedback.current + val selectionTransition = updateTransition( + targetState = isSelected, + label = "recipientSelectionContactDestinationSelection", + ) + + val containerColor by selectionTransition.animateContainerColor() + val primaryTextColor by selectionTransition.animatePrimaryTextColor() + val secondaryTextColor by selectionTransition.animateSecondaryTextColor() + + val label = rememberDestinationLabel(destination = destination) + + Row( + modifier = Modifier + .fillMaxWidth() + .testTag(rowDecorators.destinationRowTestTag(item, destination.value)) + .semantics { selected = isSelected } + .background(color = containerColor) + .combinedClickable( + enabled = enabled, + onClick = { + hapticFeedback.performHapticFeedback(HapticFeedbackType.ContextClick) + onClick() + }, + onLongClick = onLongClick?.let { callback -> + { + hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) + callback() + } + }, + ) + .padding( + start = destinationIndentation, + end = 12.dp, + top = destinationVerticalPadding, + bottom = destinationVerticalPadding, + ), + verticalAlignment = Alignment.CenterVertically, + ) { + MultiDestinationMiniRowContent( + item = item, + destination = destination, + label = label, + primaryTextColor = primaryTextColor, + secondaryTextColor = secondaryTextColor, + rowDecorators = rowDecorators, + ) + } +} + +@Composable +private fun RowScope.MultiDestinationMiniRowContent( + item: RecipientPickerListItem.Contact, + destination: ContactDestination, + label: String, + primaryTextColor: Color, + secondaryTextColor: Color, + rowDecorators: RecipientSelectionRowDecorators, +) { + Text( + modifier = Modifier.weight(weight = 1f), + text = destination.displayValue, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.bodyMedium, + color = primaryTextColor, + ) + + Text( + modifier = Modifier.padding(start = 12.dp), + text = label, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.bodySmall, + color = secondaryTextColor, + ) + + RecipientSelectionTrailingIndicator( + visible = rowDecorators.showRecipientTrailingIndicator( + item, + destination.normalizedValue, + ), + testTag = rowDecorators.trailingIndicatorTestTag, + ) +} + +@Composable +private fun rememberDestinationLabel( + destination: ContactDestination, +): String { + val resources = LocalResources.current + + return remember(destination.kind, destination.type, destination.customLabel) { + val label = when (destination.kind) { + ContactDestination.Kind.PHONE -> { + Phone.getTypeLabel(resources, destination.type, destination.customLabel) + } + + ContactDestination.Kind.EMAIL -> { + Email.getTypeLabel(resources, destination.type, destination.customLabel) + } + } + + label?.toString().orEmpty() + } +} + +@Composable +private fun RowScope.ContactPrimaryAndSecondaryText( + primaryText: String, + secondaryText: String?, primaryTextColor: Color, secondaryTextColor: Color, ) { Column( modifier = Modifier - .padding(start = 14.dp) + .padding(start = avatarToTextSpacing) .weight(weight = 1f), verticalArrangement = Arrangement.spacedBy(space = 2.dp), ) { Text( - text = recipientSelectionItemDisplayName(item = item), + text = primaryText, maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.bodyLarge, color = primaryTextColor, ) - item.secondaryText?.let { secondaryText -> + if (secondaryText != null) { Text( text = secondaryText, maxLines = 1, diff --git a/src/com/android/messaging/ui/conversation/recipientpicker/RecipientSelectionContactsContent.kt b/src/com/android/messaging/ui/conversation/recipientpicker/RecipientSelectionContactsContent.kt index 64807134..515f4535 100644 --- a/src/com/android/messaging/ui/conversation/recipientpicker/RecipientSelectionContactsContent.kt +++ b/src/com/android/messaging/ui/conversation/recipientpicker/RecipientSelectionContactsContent.kt @@ -49,8 +49,8 @@ internal fun RecipientSelectionContactsContent( rowDecorators: RecipientSelectionRowDecorators, onLoadMore: () -> Unit, onPrimaryActionClick: () -> Unit, - onRecipientClick: (RecipientPickerListItem) -> Unit, - onRecipientLongClick: ((RecipientPickerListItem) -> Unit)?, + onRecipientDestinationClick: OnRecipientDestinationAction, + onRecipientDestinationLongClick: OnRecipientDestinationAction?, modifier: Modifier = Modifier, topListContent: (@Composable () -> Unit)? = null, ) { @@ -61,8 +61,8 @@ internal fun RecipientSelectionContactsContent( uiState = uiState, rowDecorators = rowDecorators, onLoadMore = onLoadMore, - onRecipientClick = onRecipientClick, - onRecipientLongClick = onRecipientLongClick, + onRecipientDestinationClick = onRecipientDestinationClick, + onRecipientDestinationLongClick = onRecipientDestinationLongClick, topListContent = topListContent, ) @@ -91,8 +91,8 @@ private fun RecipientSelectionContactsList( uiState: RecipientSelectionContentUiState, rowDecorators: RecipientSelectionRowDecorators, onLoadMore: () -> Unit, - onRecipientClick: (RecipientPickerListItem) -> Unit, - onRecipientLongClick: ((RecipientPickerListItem) -> Unit)?, + onRecipientDestinationClick: OnRecipientDestinationAction, + onRecipientDestinationLongClick: OnRecipientDestinationAction?, topListContent: (@Composable () -> Unit)?, ) { val pickerUiState = uiState.picker @@ -129,8 +129,8 @@ private fun RecipientSelectionContactsList( recipientSelectionContactItems( uiState = uiState, rowDecorators = rowDecorators, - onRecipientClick = onRecipientClick, - onRecipientLongClick = onRecipientLongClick, + onRecipientDestinationClick = onRecipientDestinationClick, + onRecipientDestinationLongClick = onRecipientDestinationLongClick, ) } } @@ -138,8 +138,8 @@ private fun RecipientSelectionContactsList( private fun LazyListScope.recipientSelectionContactItems( uiState: RecipientSelectionContentUiState, rowDecorators: RecipientSelectionRowDecorators, - onRecipientClick: (RecipientPickerListItem) -> Unit, - onRecipientLongClick: ((RecipientPickerListItem) -> Unit)?, + onRecipientDestinationClick: OnRecipientDestinationAction, + onRecipientDestinationLongClick: OnRecipientDestinationAction?, ) { val pickerUiState = uiState.picker @@ -167,8 +167,8 @@ private fun LazyListScope.recipientSelectionContactItems( index = index, uiState = uiState, rowDecorators = rowDecorators, - onRecipientClick = onRecipientClick, - onRecipientLongClick = onRecipientLongClick, + onRecipientDestinationClick = onRecipientDestinationClick, + onRecipientDestinationLongClick = onRecipientDestinationLongClick, ) } } @@ -187,8 +187,8 @@ private fun RecipientSelectionContactItem( index: Int, uiState: RecipientSelectionContentUiState, rowDecorators: RecipientSelectionRowDecorators, - onRecipientClick: (RecipientPickerListItem) -> Unit, - onRecipientLongClick: ((RecipientPickerListItem) -> Unit)?, + onRecipientDestinationClick: OnRecipientDestinationAction, + onRecipientDestinationLongClick: OnRecipientDestinationAction?, ) { val lastContactIndex = uiState.picker.items.lastIndex val bottomPadding = when { @@ -200,22 +200,20 @@ private fun RecipientSelectionContactItem( modifier = Modifier.padding(bottom = bottomPadding), item = item, enabled = uiState.primaryAction?.isLoading != true, - isSelected = uiState.selectedRecipientDestinations.contains(item.destination), - onClick = { - onRecipientClick(item) + selectedDestinations = uiState.selectedRecipientDestinations, + onDestinationClick = { destination -> + onRecipientDestinationClick(item, destination) }, - onLongClick = onRecipientLongClick?.let { callback -> - { - callback(item) + onDestinationLongClick = onRecipientDestinationLongClick?.let { callback -> + { destination -> + callback(item, destination) } }, - rowTestTag = rowDecorators.recipientRowTestTag(item), + rowDecorators = rowDecorators, shape = recipientSelectionContactRowShape( index = index, totalCount = uiState.picker.items.size, ), - showTrailingIndicator = rowDecorators.showRecipientTrailingIndicator(item), - trailingIndicatorTestTag = rowDecorators.trailingIndicatorTestTag, ) } diff --git a/src/com/android/messaging/ui/conversation/recipientpicker/RecipientSelectionContent.kt b/src/com/android/messaging/ui/conversation/recipientpicker/RecipientSelectionContent.kt index 09a57c07..efdc414c 100644 --- a/src/com/android/messaging/ui/conversation/recipientpicker/RecipientSelectionContent.kt +++ b/src/com/android/messaging/ui/conversation/recipientpicker/RecipientSelectionContent.kt @@ -21,7 +21,6 @@ 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.recipientpicker.model.RecipientPickerListItem private val searchFieldShape = RoundedCornerShape(size = 22.dp) @@ -30,12 +29,12 @@ internal fun RecipientSelectionContent( uiState: RecipientSelectionContentUiState, strings: RecipientSelectionStrings, rowDecorators: RecipientSelectionRowDecorators, - onRecipientClick: (RecipientPickerListItem) -> Unit, + onRecipientDestinationClick: OnRecipientDestinationAction, modifier: Modifier = Modifier, onLoadMore: () -> Unit = {}, onPrimaryActionClick: () -> Unit = {}, onQueryChanged: (String) -> Unit = {}, - onRecipientLongClick: ((RecipientPickerListItem) -> Unit)? = null, + onRecipientDestinationLongClick: OnRecipientDestinationAction? = null, topListContent: (@Composable () -> Unit)? = null, ) { Surface( @@ -65,8 +64,8 @@ internal fun RecipientSelectionContent( rowDecorators = rowDecorators, onLoadMore = onLoadMore, onPrimaryActionClick = onPrimaryActionClick, - onRecipientClick = onRecipientClick, - onRecipientLongClick = onRecipientLongClick, + onRecipientDestinationClick = onRecipientDestinationClick, + onRecipientDestinationLongClick = onRecipientDestinationLongClick, topListContent = topListContent, ) } diff --git a/src/com/android/messaging/ui/conversation/recipientpicker/RecipientSelectionContentUiState.kt b/src/com/android/messaging/ui/conversation/recipientpicker/RecipientSelectionContentUiState.kt index d98a3cab..8053eef7 100644 --- a/src/com/android/messaging/ui/conversation/recipientpicker/RecipientSelectionContentUiState.kt +++ b/src/com/android/messaging/ui/conversation/recipientpicker/RecipientSelectionContentUiState.kt @@ -6,6 +6,18 @@ import com.android.messaging.ui.conversation.recipientpicker.model.RecipientPick import kotlinx.collections.immutable.ImmutableSet import kotlinx.collections.immutable.persistentSetOf +internal typealias OnRecipientDestinationAction = + (item: RecipientPickerListItem, destination: String) -> Unit + +internal typealias RecipientRowTestTagProvider = + (item: RecipientPickerListItem) -> String + +internal typealias RecipientDestinationTestTagProvider = + (item: RecipientPickerListItem, destination: String) -> String + +internal typealias ShouldShowRecipientTrailingIndicator = + (item: RecipientPickerListItem, destination: String) -> Boolean + @Immutable internal data class RecipientSelectionContentUiState( val picker: RecipientPickerUiState = RecipientPickerUiState(), @@ -29,7 +41,9 @@ internal data class RecipientSelectionStrings( ) internal data class RecipientSelectionRowDecorators( - val recipientRowTestTag: (RecipientPickerListItem) -> String, - val showRecipientTrailingIndicator: (RecipientPickerListItem) -> Boolean = { false }, + val recipientRowTestTag: RecipientRowTestTagProvider, + val destinationRowTestTag: RecipientDestinationTestTagProvider = + { item, _ -> recipientRowTestTag(item) }, + val showRecipientTrailingIndicator: ShouldShowRecipientTrailingIndicator = { _, _ -> false }, val trailingIndicatorTestTag: String? = null, ) diff --git a/src/com/android/messaging/ui/conversation/recipientpicker/delegate/RecipientPickerDelegate.kt b/src/com/android/messaging/ui/conversation/recipientpicker/delegate/RecipientPickerDelegate.kt index 845c50f4..70d8491d 100644 --- a/src/com/android/messaging/ui/conversation/recipientpicker/delegate/RecipientPickerDelegate.kt +++ b/src/com/android/messaging/ui/conversation/recipientpicker/delegate/RecipientPickerDelegate.kt @@ -1,19 +1,22 @@ package com.android.messaging.ui.conversation.recipientpicker.delegate import androidx.lifecycle.SavedStateHandle -import com.android.messaging.data.conversation.model.recipient.ConversationRecipient -import com.android.messaging.data.conversation.model.recipient.ConversationRecipientsPage -import com.android.messaging.data.conversation.repository.ConversationRecipientsRepository +import com.android.messaging.data.contact.formatter.ContactDestinationFormatter +import com.android.messaging.data.contact.model.Contact +import com.android.messaging.data.contact.model.ContactDestination +import com.android.messaging.data.contact.model.ContactsPage +import com.android.messaging.data.contact.repository.ContactsRepository 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.recipientpicker.model.RecipientPickerListItem import com.android.messaging.ui.conversation.recipientpicker.model.RecipientPickerUiState import com.android.messaging.util.PhoneUtils import javax.inject.Inject +import kotlin.time.Duration.Companion.milliseconds import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList +import kotlinx.collections.immutable.toPersistentList import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.delay @@ -41,7 +44,8 @@ internal interface RecipientPickerDelegate { } internal class RecipientPickerDelegateImpl @Inject constructor( - private val conversationRecipientsRepository: ConversationRecipientsRepository, + private val contactDestinationFormatter: ContactDestinationFormatter, + private val contactsRepository: ContactsRepository, private val isReadContactsPermissionGranted: IsReadContactsPermissionGranted, private val savedStateHandle: SavedStateHandle, @param:DefaultDispatcher @@ -66,9 +70,7 @@ internal class RecipientPickerDelegateImpl @Inject constructor( private var boundScope: CoroutineScope? = null - private val phoneUtils by lazy { PhoneUtils.getDefault() } - - private var searchSession = RecipientSearchSession( + private var searchSession = ContactSearchSession( effectiveQuery = queryFlow.value, hasCompletedInitialLoad = false, nextPageOffset = null, @@ -106,13 +108,13 @@ internal class RecipientPickerDelegateImpl @Inject constructor( } override fun onExcludedDestinationsChanged(destinations: Set) { - val normalizedDestinations = destinations + val canonicalDestinations = destinations .asSequence() - .map { it.trim() } + .map { contactDestinationFormatter.canonicalize(value = it) } .filter { it.isNotEmpty() } .toSet() - excludedDestinationsFlow.value = normalizedDestinations + excludedDestinationsFlow.value = canonicalDestinations } override fun onQueryChanged(query: String) { @@ -133,23 +135,21 @@ internal class RecipientPickerDelegateImpl @Inject constructor( startSearch(searchInputs = searchInputs) } - private fun mergeRecipients( - existingRecipients: List, - additionalRecipients: List, - ): ImmutableList { - val seenDestinations = LinkedHashSet() + private fun mergeContacts( + existingContacts: List, + additionalContacts: List, + ): ImmutableList { + val seenContactIds = LinkedHashSet() - return (existingRecipients + additionalRecipients) + return (existingContacts + additionalContacts) .asSequence() - .filter { recipient -> - seenDestinations.add(recipient.destination) - } + .filter { contact -> seenContactIds.add(contact.id) } .toImmutableList() } private suspend fun startSearch(searchInputs: SearchInputs) { applySearchStartedState() - delay(timeMillis = SEARCH_DEBOUNCE_MILLIS) + delay(searchDebounce) val initialSearchResult = resolveInitialSearch(searchInputs = searchInputs) updateSearchSession { currentSearchSession -> @@ -164,9 +164,9 @@ internal class RecipientPickerDelegateImpl @Inject constructor( } private suspend fun applyPermissionDeniedState(query: String) { - val visibleRecipients = buildVisibleRecipients( + val visibleItems = buildVisibleItems( query = query, - recipients = persistentListOf(), + contacts = persistentListOf(), excludedDestinations = excludedDestinationsFlow.value, ) @@ -180,7 +180,7 @@ internal class RecipientPickerDelegateImpl @Inject constructor( _state.update { currentState -> currentState.copy( canLoadMore = false, - items = visibleRecipients, + items = visibleItems, hasContactsPermission = false, isLoading = false, isLoadingMore = false, @@ -206,7 +206,7 @@ internal class RecipientPickerDelegateImpl @Inject constructor( private suspend fun resolveInitialSearch( searchInputs: SearchInputs, ): InitialSearchResult { - val requestedPage = loadRecipientsPage( + val requestedPage = loadContactsPage( query = searchInputs.query, offset = 0, excludedDestinations = searchInputs.excludedDestinations, @@ -219,7 +219,7 @@ internal class RecipientPickerDelegateImpl @Inject constructor( ) } - val defaultPage = loadRecipientsPage( + val defaultPage = loadContactsPage( query = "", offset = 0, excludedDestinations = searchInputs.excludedDestinations, @@ -233,36 +233,41 @@ internal class RecipientPickerDelegateImpl @Inject constructor( private fun shouldUseRequestedPage( query: String, - page: ConversationRecipientsPage, + page: ContactsPage, ): Boolean { - return query.isBlank() || page.recipients.isNotEmpty() + return query.isBlank() || page.contacts.isNotEmpty() } - private suspend fun loadRecipientsPage( + private suspend fun loadContactsPage( query: String, offset: Int, excludedDestinations: Set, - ): ConversationRecipientsPage { + ): ContactsPage { var nextOffset: Int? = offset - val visibleRecipients = mutableListOf() + val visibleContacts = mutableListOf() while (nextOffset != null) { - val rawPage = conversationRecipientsRepository - .searchRecipients( + val rawPage = contactsRepository + .searchContacts( query = query, offset = nextOffset, ) .first() - visibleRecipients.addAll( - rawPage.recipients.filterNot { recipient -> - recipient.destination in excludedDestinations - }, - ) + rawPage.contacts.forEach { contact -> + val filtered = filterExcludedDestinations( + contact = contact, + excludedDestinations = excludedDestinations, + ) + + if (filtered != null) { + visibleContacts.add(filtered) + } + } - if (visibleRecipients.isNotEmpty() || rawPage.nextOffset == null) { - return ConversationRecipientsPage( - recipients = visibleRecipients.toImmutableList(), + if (visibleContacts.isNotEmpty() || rawPage.nextOffset == null) { + return ContactsPage( + contacts = visibleContacts.toImmutableList(), nextOffset = rawPage.nextOffset, ) } @@ -270,18 +275,39 @@ internal class RecipientPickerDelegateImpl @Inject constructor( nextOffset = rawPage.nextOffset } - return ConversationRecipientsPage( - recipients = persistentListOf(), + return ContactsPage( + contacts = persistentListOf(), nextOffset = null, ) } + private fun filterExcludedDestinations( + contact: Contact, + excludedDestinations: Set, + ): Contact? { + if (excludedDestinations.isEmpty()) { + return contact + } + + val remainingDestinations = contact.destinations + .filterNot { destination -> destination.normalizedValue in excludedDestinations } + .toPersistentList() + + return when { + remainingDestinations.isEmpty() -> null + remainingDestinations.size == contact.destinations.size -> contact + else -> { + contact.copy(destinations = remainingDestinations) + } + } + } + private fun applyInitialSearchResult(result: InitialSearchResult) { _state.update { currentState -> currentState.copy( - items = buildVisibleRecipients( + items = buildVisibleItems( query = currentState.query, - recipients = result.page.recipients, + contacts = result.page.contacts, excludedDestinations = excludedDestinationsFlow.value, ), canLoadMore = result.page.nextOffset != null, @@ -317,7 +343,7 @@ internal class RecipientPickerDelegateImpl @Inject constructor( private suspend fun loadMore(request: LoadMoreRequest) { applyLoadMoreStartedState() - val nextPage = loadRecipientsPage( + val nextPage = loadContactsPage( query = request.effectiveQuery, offset = request.offset, excludedDestinations = request.excludedDestinations, @@ -362,26 +388,26 @@ internal class RecipientPickerDelegateImpl @Inject constructor( } } - private fun applyLoadMoreResult(page: ConversationRecipientsPage) { + private fun applyLoadMoreResult(page: ContactsPage) { _state.update { currentState -> - val mergedRecipients = mergeRecipients( - existingRecipients = currentState.items.mapNotNull { item -> + val mergedContacts = mergeContacts( + existingContacts = currentState.items.mapNotNull { item -> when (item) { - is RecipientPickerListItem.Contact -> item.recipient + is RecipientPickerListItem.Contact -> item.contact is RecipientPickerListItem.SyntheticPhone -> null } }, - additionalRecipients = page.recipients, + additionalContacts = page.contacts, ) - val visibleRecipients = buildVisibleRecipients( + val visibleItems = buildVisibleItems( query = currentState.query, - recipients = mergedRecipients, + contacts = mergedContacts, excludedDestinations = excludedDestinationsFlow.value, ) currentState.copy( - items = visibleRecipients, + items = visibleItems, canLoadMore = page.nextOffset != null, isLoadingMore = false, ) @@ -397,60 +423,67 @@ internal class RecipientPickerDelegateImpl @Inject constructor( } private suspend fun updateSearchSession( - transform: (RecipientSearchSession) -> RecipientSearchSession, + transform: (ContactSearchSession) -> ContactSearchSession, ) { searchSessionMutex.withLock { searchSession = transform(searchSession) } } - private fun buildVisibleRecipients( + private fun buildVisibleItems( query: String, - recipients: List, + contacts: List, excludedDestinations: Set, ): ImmutableList { - val syntheticRecipient = createSyntheticRecipientOrNull( + val syntheticItem = createSyntheticItemOrNull( query = query, - recipients = recipients, + contacts = contacts, excludedDestinations = excludedDestinations, ) - val contactItems = recipients + val contactItems = contacts .map(RecipientPickerListItem::Contact) .toImmutableList() - if (syntheticRecipient == null) { - return contactItems + return when { + syntheticItem == null -> contactItems + else -> { + persistentListOf(syntheticItem) + .addAll(contactItems) + } } - - return persistentListOf(syntheticRecipient) - .addAll(contactItems) } - private fun createSyntheticRecipientOrNull( + private fun createSyntheticItemOrNull( query: String, - recipients: List, + contacts: List, excludedDestinations: Set, ): RecipientPickerListItem.SyntheticPhone? { - val candidate = createSyntheticRecipientCandidateOrNull(query = query) ?: return null + val candidate = createSyntheticCandidateOrNull(query = query) ?: return null + + val isAlreadyAContactDestination = contacts.any { contact -> + contact.destinations.any { destination -> + candidate.matchesDestination(destination = destination) + } + } return when { candidate.isExcludedBy(excludedDestinations) -> null - recipients.any { recipient -> candidate.matches(recipient) } -> null + isAlreadyAContactDestination -> null else -> candidate.toListItem() } } - private fun createSyntheticRecipientCandidateOrNull( + private fun createSyntheticCandidateOrNull( query: String, - ): SyntheticRecipientCandidate? { + ): SyntheticCandidate? { val trimmedQuery = query.trim() return when { trimmedQuery.isEmpty() -> null !PhoneUtils.isValidSmsMmsDestination(trimmedQuery) -> null else -> { - SyntheticRecipientCandidate( + SyntheticCandidate( rawQuery = trimmedQuery, destinationIdentity = createDestinationIdentity( rawDestination = trimmedQuery, @@ -469,25 +502,21 @@ internal class RecipientPickerDelegateImpl @Inject constructor( ) } - private fun SyntheticRecipientCandidate.matches(recipient: ConversationRecipient): Boolean { + private fun SyntheticCandidate.matchesDestination( + destination: ContactDestination, + ): Boolean { return destinationIdentity.matches( - other = createDestinationIdentity(rawDestination = recipient.destination), + other = createDestinationIdentity(rawDestination = destination.value), ) } private fun normalizeDestination(rawDestination: String): String { - val trimmedDestination = rawDestination.trim() - - return when { - trimmedDestination.isEmpty() -> trimmedDestination - MmsSmsUtils.isEmailAddress(trimmedDestination) -> trimmedDestination - else -> phoneUtils.getCanonicalForEnteredPhoneNumber(trimmedDestination) - } + return contactDestinationFormatter.canonicalize(value = rawDestination) } private data class InitialSearchResult( val effectiveQuery: String, - val page: ConversationRecipientsPage, + val page: ContactsPage, ) private data class LoadMoreRequest( @@ -497,7 +526,7 @@ internal class RecipientPickerDelegateImpl @Inject constructor( val offset: Int, ) - private data class RecipientSearchSession( + private data class ContactSearchSession( val effectiveQuery: String, val hasCompletedInitialLoad: Boolean, val nextPageOffset: Int?, @@ -528,7 +557,7 @@ internal class RecipientPickerDelegateImpl @Inject constructor( } } - private data class SyntheticRecipientCandidate( + private data class SyntheticCandidate( val rawQuery: String, val destinationIdentity: DestinationIdentity, ) { @@ -547,7 +576,7 @@ internal class RecipientPickerDelegateImpl @Inject constructor( } private companion object { - private const val SEARCH_DEBOUNCE_MILLIS = 150L + private val searchDebounce = 150L.milliseconds 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/recipientpicker/model/RecipientPickerListItem.kt b/src/com/android/messaging/ui/conversation/recipientpicker/model/RecipientPickerListItem.kt index a0729dda..3f8550bb 100644 --- a/src/com/android/messaging/ui/conversation/recipientpicker/model/RecipientPickerListItem.kt +++ b/src/com/android/messaging/ui/conversation/recipientpicker/model/RecipientPickerListItem.kt @@ -1,28 +1,29 @@ package com.android.messaging.ui.conversation.recipientpicker.model import androidx.compose.runtime.Immutable -import com.android.messaging.data.conversation.model.recipient.ConversationRecipient +import com.android.messaging.data.contact.model.ContactDestination +import kotlinx.collections.immutable.ImmutableList @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 + val contact: com.android.messaging.data.contact.model.Contact, + ) : RecipientPickerListItem { + override val id: String = "contact:${contact.id}" + + val destinations: ImmutableList + get() = contact.destinations + } @Immutable data class SyntheticPhone( override val id: String, val rawQuery: String, - override val destination: String, + val destination: String, val normalizedDestination: String, - override val secondaryText: String = normalizedDestination, + val secondaryText: String = normalizedDestination, ) : RecipientPickerListItem } diff --git a/src/com/android/messaging/util/PhoneUtils.java b/src/com/android/messaging/util/PhoneUtils.java index ab1ebbb1..ce63a4c5 100644 --- a/src/com/android/messaging/util/PhoneUtils.java +++ b/src/com/android/messaging/util/PhoneUtils.java @@ -39,7 +39,6 @@ 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; @@ -560,8 +559,7 @@ public String getCanonicalForEnteredPhoneNumber(@NonNull final String phoneText) } if (phoneText.charAt(0) == '+') { - final String canonicalNumber = getValidE164Number(phoneText, null); - return canonicalNumber != null ? canonicalNumber : phoneText; + return canonicalizeE164(phoneText); } return getCanonicalByCountryCandidates( @@ -570,6 +568,49 @@ public String getCanonicalForEnteredPhoneNumber(@NonNull final String phoneText) ); } + /** + * Same as {@link #getCanonicalForEnteredPhoneNumber(String)} but reuses a precomputed list of country candidates. + * Bulk callers (e.g. canonicalizing every row in a contacts page) should compute the candidate list once and + * pass it here to avoid re-running the telephony IPC required to assemble it on every call. + */ + public String getCanonicalForEnteredPhoneNumber( + @NonNull final String phoneText, + @NonNull final List countryCandidates + ) { + if (phoneText.isEmpty()) { + return phoneText; + } + + if (phoneText.charAt(0) == '+') { + return canonicalizeE164(phoneText); + } + + return getCanonicalByCountryCandidates(phoneText, countryCandidates); + } + + private String canonicalizeE164(@NonNull final String phoneText) { + final String cachedCanonicalNumber = getCanonicalFromCache(phoneText, null); + if (cachedCanonicalNumber != null) { + return cachedCanonicalNumber; + } + + final String canonicalNumber = getValidE164Number(phoneText, null); + final String resolvedCanonicalNumber = canonicalNumber != null + ? canonicalNumber : phoneText; + + putCanonicalToCache(phoneText, null, resolvedCanonicalNumber); + + return resolvedCanonicalNumber; + } + + /** + * Trigger libphonenumber metadata initialization eagerly so the first canonicalization call doesn't pay it. + * Cheap on later invocations — libphonenumber's singleton initializes only once per process. + */ + public void warmUp() { + PhoneNumberUtil.getInstance(); + } + @NonNull private String getCanonicalByCountryCandidates( @NonNull final String phoneText, @@ -607,8 +648,7 @@ public String getCanonicalBySimLocale(final String phoneText) { return getCanonicalByCountry(phoneText, getSimOrDefaultLocaleCountry()); } - @VisibleForTesting - List getCountryCandidatesForEnteredPhoneNumber() { + public List getCountryCandidatesForEnteredPhoneNumber() { final LinkedHashSet uniqueCountries = new LinkedHashSet<>(); String normalizedCountryCode = normalizeCountryCode(getNetworkCountry()); if (normalizedCountryCode != null) { From 826f1763a5583a15ba1b7bba2f36f927429d0a9b Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Tue, 12 May 2026 18:42:38 +0300 Subject: [PATCH 111/136] Auto focus query field in NewChatScreen --- .../ui/conversation/entry/NewChatScreen.kt | 1 + .../RecipientSelectionContent.kt | 19 ++++++++++++++++++- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/src/com/android/messaging/ui/conversation/entry/NewChatScreen.kt b/src/com/android/messaging/ui/conversation/entry/NewChatScreen.kt index d7bfc1ce..f8b144be 100644 --- a/src/com/android/messaging/ui/conversation/entry/NewChatScreen.kt +++ b/src/com/android/messaging/ui/conversation/entry/NewChatScreen.kt @@ -185,6 +185,7 @@ private fun NewChatRecipientSelectionContent( } }, modifier = modifier, + autoFocusQuery = true, onLoadMore = onLoadMore, onPrimaryActionClick = onCreateGroupConfirmed, onQueryChanged = onQueryChanged, diff --git a/src/com/android/messaging/ui/conversation/recipientpicker/RecipientSelectionContent.kt b/src/com/android/messaging/ui/conversation/recipientpicker/RecipientSelectionContent.kt index efdc414c..fb61dd36 100644 --- a/src/com/android/messaging/ui/conversation/recipientpicker/RecipientSelectionContent.kt +++ b/src/com/android/messaging/ui/conversation/recipientpicker/RecipientSelectionContent.kt @@ -18,7 +18,11 @@ 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.remember 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.unit.dp @@ -31,12 +35,21 @@ internal fun RecipientSelectionContent( rowDecorators: RecipientSelectionRowDecorators, onRecipientDestinationClick: OnRecipientDestinationAction, modifier: Modifier = Modifier, + autoFocusQuery: Boolean = false, onLoadMore: () -> Unit = {}, onPrimaryActionClick: () -> Unit = {}, onQueryChanged: (String) -> Unit = {}, onRecipientDestinationLongClick: OnRecipientDestinationAction? = null, topListContent: (@Composable () -> Unit)? = null, ) { + val queryFocusRequester = remember { FocusRequester() } + + if (autoFocusQuery) { + LaunchedEffect(Unit) { + queryFocusRequester.requestFocus() + } + } + Surface( modifier = modifier, color = MaterialTheme.colorScheme.surfaceVariant, @@ -54,6 +67,7 @@ internal fun RecipientSelectionContent( prefixText = strings.queryPrefixText, placeholderText = strings.queryPlaceholderText, onQueryChanged = onQueryChanged, + focusRequester = queryFocusRequester, ) Spacer(modifier = Modifier.height(height = 12.dp)) @@ -79,9 +93,12 @@ private fun RecipientSelectionQueryField( prefixText: String, placeholderText: String, onQueryChanged: (String) -> Unit, + focusRequester: FocusRequester, ) { TextField( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .focusRequester(focusRequester = focusRequester), value = query, onValueChange = onQueryChanged, enabled = enabled, From 8bf8703afd5b38099a9b076e45092e077079b544 Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Tue, 12 May 2026 19:52:35 +0300 Subject: [PATCH 112/136] Add SIM chooser to new chat screen and improve packages organization --- .../ResolveDraftAttachmentsWithinLimitTest.kt | 32 +-- res/values/strings.xml | 4 + .../model/Subscription.kt} | 6 +- .../repository/SubscriptionsRepository.kt} | 51 ++-- .../android/messaging/debug/TestDataSeeder.kt | 4 +- .../conversation/ConversationBindsModule.kt | 10 +- .../ResolveDraftAttachmentsWithinLimit.kt | 6 +- .../usecase/draft/SendConversationDraft.kt | 6 +- .../ui/conversation/ConversationTestTags.kt | 6 + .../addparticipants/AddParticipantsScreen.kt | 10 +- .../ConversationAudioRecordingDelegate.kt | 6 +- .../ConversationDraftEditorDelegate.kt | 6 +- .../ConversationComposerUiStateMapper.kt | 8 +- .../model/ConversationSimSelectorUiState.kt | 6 +- .../composer/ui/ConversationSimAvatar.kt | 52 ++++ .../ui/ConversationSimSelectorSheet.kt | 42 +--- .../entry/ConversationEntryViewModel.kt | 147 ++++++++++- .../ui/conversation/entry/NewChatScreen.kt | 68 ++++-- .../entry/model/ConversationEntryUiState.kt | 3 + .../messages/ui/ConversationMessages.kt | 4 +- .../navigation/ConversationNavGraph.kt | 8 + .../RecipientSelectionContactAvatar.kt | 2 +- .../RecipientSelectionContactRow.kt | 2 +- .../RecipientSelectionContactsContent.kt | 2 +- .../RecipientSelectionContent.kt | 45 +++- .../RecipientSelectionContentUiState.kt | 2 +- .../RecipientSelectionPrimaryActionButton.kt | 2 +- .../simselector/NewChatSimSelector.kt | 231 ++++++++++++++++++ .../conversation/screen/ConversationScreen.kt | 4 + .../screen/ConversationScreenRoute.kt | 22 ++ .../screen/ConversationViewModel.kt | 6 +- 31 files changed, 642 insertions(+), 161 deletions(-) rename src/com/android/messaging/data/{conversation/model/metadata/ConversationSubscription.kt => subscription/model/Subscription.kt} (52%) rename src/com/android/messaging/data/{conversation/repository/ConversationSubscriptionsRepository.kt => subscription/repository/SubscriptionsRepository.kt} (86%) create mode 100644 src/com/android/messaging/ui/conversation/composer/ui/ConversationSimAvatar.kt rename src/com/android/messaging/ui/conversation/recipientpicker/{ => component}/RecipientSelectionContactAvatar.kt (98%) rename src/com/android/messaging/ui/conversation/recipientpicker/{ => component}/RecipientSelectionContactRow.kt (99%) rename src/com/android/messaging/ui/conversation/recipientpicker/{ => component}/RecipientSelectionContactsContent.kt (99%) rename src/com/android/messaging/ui/conversation/recipientpicker/{ => component}/RecipientSelectionContent.kt (79%) rename src/com/android/messaging/ui/conversation/recipientpicker/{ => component}/RecipientSelectionContentUiState.kt (96%) rename src/com/android/messaging/ui/conversation/recipientpicker/{ => component}/RecipientSelectionPrimaryActionButton.kt (98%) create mode 100644 src/com/android/messaging/ui/conversation/recipientpicker/component/simselector/NewChatSimSelector.kt diff --git a/app/src/test/kotlin/com/android/messaging/domain/conversation/usecase/draft/ResolveDraftAttachmentsWithinLimitTest.kt b/app/src/test/kotlin/com/android/messaging/domain/conversation/usecase/draft/ResolveDraftAttachmentsWithinLimitTest.kt index 558c9a98..f1b3ea8b 100644 --- a/app/src/test/kotlin/com/android/messaging/domain/conversation/usecase/draft/ResolveDraftAttachmentsWithinLimitTest.kt +++ b/app/src/test/kotlin/com/android/messaging/domain/conversation/usecase/draft/ResolveDraftAttachmentsWithinLimitTest.kt @@ -1,11 +1,9 @@ package com.android.messaging.domain.conversation.usecase.draft import com.android.messaging.data.conversation.model.draft.ConversationDraftAttachment -import com.android.messaging.data.conversation.model.metadata.ConversationSubscription -import com.android.messaging.data.conversation.repository.ConversationSubscriptionsRepository -import kotlinx.collections.immutable.ImmutableList -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.emptyFlow +import com.android.messaging.data.subscription.repository.SubscriptionsRepository +import io.mockk.every +import io.mockk.mockk import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue @@ -85,27 +83,11 @@ class ResolveDraftAttachmentsWithinLimitTest { private fun createResolveDraftAttachmentsWithinLimit( attachmentLimit: Int, ): ResolveDraftAttachmentsWithinLimit { + val subscriptionsRepository = mockk() + every { subscriptionsRepository.resolveAttachmentLimit() } returns attachmentLimit + return ResolveDraftAttachmentsWithinLimitImpl( - conversationSubscriptionsRepository = FakeConversationSubscriptionsRepository( - attachmentLimit = attachmentLimit, - ), + subscriptionsRepository = subscriptionsRepository, ) } - - private class FakeConversationSubscriptionsRepository( - private val attachmentLimit: Int, - ) : ConversationSubscriptionsRepository { - - override fun observeActiveSubscriptions(): Flow> { - return emptyFlow() - } - - override fun resolveAttachmentLimit(): Int { - return attachmentLimit - } - - override fun resolveMaxMessageSize(selfParticipantId: String): Flow { - return emptyFlow() - } - } } diff --git a/res/values/strings.xml b/res/values/strings.xml index 68b4f4c1..f427b158 100644 --- a/res/values/strings.xml +++ b/res/values/strings.xml @@ -331,6 +331,10 @@ To: Type name or phone number + + With: + + %1$s selected, choose SIM ,\u0020 diff --git a/src/com/android/messaging/data/conversation/model/metadata/ConversationSubscription.kt b/src/com/android/messaging/data/subscription/model/Subscription.kt similarity index 52% rename from src/com/android/messaging/data/conversation/model/metadata/ConversationSubscription.kt rename to src/com/android/messaging/data/subscription/model/Subscription.kt index 95cdc7e3..fe22596f 100644 --- a/src/com/android/messaging/data/conversation/model/metadata/ConversationSubscription.kt +++ b/src/com/android/messaging/data/subscription/model/Subscription.kt @@ -1,10 +1,12 @@ -package com.android.messaging.data.conversation.model.metadata +package com.android.messaging.data.subscription.model import androidx.compose.runtime.Immutable +import com.android.messaging.data.conversation.model.metadata.ConversationSubscriptionLabel @Immutable -internal data class ConversationSubscription( +internal data class Subscription( val selfParticipantId: String, + val subId: Int, val label: ConversationSubscriptionLabel, val displayDestination: String?, val displaySlotId: Int, diff --git a/src/com/android/messaging/data/conversation/repository/ConversationSubscriptionsRepository.kt b/src/com/android/messaging/data/subscription/repository/SubscriptionsRepository.kt similarity index 86% rename from src/com/android/messaging/data/conversation/repository/ConversationSubscriptionsRepository.kt rename to src/com/android/messaging/data/subscription/repository/SubscriptionsRepository.kt index a2096d7f..93d001ac 100644 --- a/src/com/android/messaging/data/conversation/repository/ConversationSubscriptionsRepository.kt +++ b/src/com/android/messaging/data/subscription/repository/SubscriptionsRepository.kt @@ -1,10 +1,10 @@ -package com.android.messaging.data.conversation.repository +package com.android.messaging.data.subscription.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.data.subscription.model.Subscription import com.android.messaging.datamodel.DatabaseHelper.ParticipantColumns import com.android.messaging.datamodel.MessagingContentProvider import com.android.messaging.datamodel.data.ParticipantData @@ -15,6 +15,7 @@ import com.android.messaging.sms.MmsConfig import com.android.messaging.util.BugleGservices import com.android.messaging.util.BugleGservicesKeys import com.android.messaging.util.LogUtil +import com.android.messaging.util.PhoneUtils import com.android.messaging.util.core.extension.typedFlow import javax.inject.Inject import kotlinx.collections.immutable.ImmutableList @@ -31,22 +32,24 @@ import kotlinx.coroutines.flow.conflate import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map -internal interface ConversationSubscriptionsRepository { - fun observeActiveSubscriptions(): Flow> +internal interface SubscriptionsRepository { + fun observeActiveSubscriptions(): Flow> + + fun getDefaultSmsSubscriptionId(): Int fun resolveAttachmentLimit(): Int fun resolveMaxMessageSize(selfParticipantId: String): Flow } -internal class ConversationSubscriptionsRepositoryImpl @Inject constructor( +internal class SubscriptionsRepositoryImpl @Inject constructor( private val contentResolver: ContentResolver, private val debugSimEmulationSource: DebugSimEmulationSource, @param:IoDispatcher private val ioDispatcher: CoroutineDispatcher, -) : ConversationSubscriptionsRepository { +) : SubscriptionsRepository { - override fun observeActiveSubscriptions(): Flow> { + override fun observeActiveSubscriptions(): Flow> { val uri = MessagingContentProvider.PARTICIPANTS_URI val realSubscriptions = observeUri(uri = uri) @@ -67,6 +70,10 @@ internal class ConversationSubscriptionsRepositoryImpl @Inject constructor( } } + override fun getDefaultSmsSubscriptionId(): Int { + return PhoneUtils.getDefault().defaultSmsSubscriptionId + } + override fun resolveAttachmentLimit(): Int { return BugleGservices .get() @@ -90,9 +97,9 @@ internal class ConversationSubscriptionsRepositoryImpl @Inject constructor( } private fun applyDebugEmulation( - subscriptions: ImmutableList, + subscriptions: ImmutableList, mode: DebugSimEmulationMode, - ): ImmutableList { + ): ImmutableList { return when (mode) { DebugSimEmulationMode.DEFAULT -> subscriptions DebugSimEmulationMode.SINGLE -> applySingleSimEmulation(subscriptions = subscriptions) @@ -101,8 +108,8 @@ internal class ConversationSubscriptionsRepositoryImpl @Inject constructor( } private fun applySingleSimEmulation( - subscriptions: ImmutableList, - ): ImmutableList { + subscriptions: ImmutableList, + ): ImmutableList { val hasRealSubscription = subscriptions.isNotEmpty() if (hasRealSubscription) { @@ -115,8 +122,8 @@ internal class ConversationSubscriptionsRepositoryImpl @Inject constructor( } private fun applyDualSimEmulation( - subscriptions: ImmutableList, - ): ImmutableList { + subscriptions: ImmutableList, + ): ImmutableList { return when (subscriptions.size) { 0 -> { persistentListOf( @@ -132,8 +139,8 @@ internal class ConversationSubscriptionsRepositoryImpl @Inject constructor( } private fun pairRealSubscriptionWithFake( - realSubscription: ConversationSubscription, - ): ImmutableList { + realSubscription: Subscription, + ): ImmutableList { val fakeSlot = when (realSubscription.displaySlotId) { 1 -> 2 else -> 1 @@ -149,9 +156,10 @@ internal class ConversationSubscriptionsRepositoryImpl @Inject constructor( private fun fakeSubscription( slotId: Int, colorIndex: Int, - ): ConversationSubscription { - return ConversationSubscription( + ): Subscription { + return Subscription( selfParticipantId = "$FAKE_SIM_ID_PREFIX$slotId", + subId = ParticipantData.DEFAULT_SELF_SUB_ID, label = ConversationSubscriptionLabel.DebugFake(slotId = slotId), displayDestination = null, displaySlotId = slotId, @@ -176,7 +184,7 @@ internal class ConversationSubscriptionsRepositoryImpl @Inject constructor( } } - private fun queryActiveSubscriptions(): ImmutableList { + private fun queryActiveSubscriptions(): ImmutableList { return contentResolver .query( MessagingContentProvider.PARTICIPANTS_URI, @@ -186,7 +194,7 @@ internal class ConversationSubscriptionsRepositoryImpl @Inject constructor( null, ) ?.use { cursor -> - val subscriptions = persistentListOf().builder() + val subscriptions = persistentListOf().builder() while (cursor.moveToNext()) { val participant = ParticipantData.getFromCursor(cursor) @@ -256,11 +264,12 @@ internal class ConversationSubscriptionsRepositoryImpl @Inject constructor( ) } - private fun ParticipantData.toConversationSubscription(): ConversationSubscription { + private fun ParticipantData.toConversationSubscription(): Subscription { val slotId = displaySlotId - return ConversationSubscription( + return Subscription( selfParticipantId = id, + subId = subId, label = when { subscriptionName.isNullOrBlank() -> ConversationSubscriptionLabel.Slot( slotId = slotId, diff --git a/src/com/android/messaging/debug/TestDataSeeder.kt b/src/com/android/messaging/debug/TestDataSeeder.kt index e75455c9..0aa37c60 100644 --- a/src/com/android/messaging/debug/TestDataSeeder.kt +++ b/src/com/android/messaging/debug/TestDataSeeder.kt @@ -12,7 +12,7 @@ import android.graphics.Paint import android.net.Uri import androidx.core.graphics.createBitmap import androidx.core.net.toUri -import com.android.messaging.data.conversation.repository.ConversationSubscriptionsRepository +import com.android.messaging.data.subscription.repository.SubscriptionsRepository import com.android.messaging.datamodel.DataModel import com.android.messaging.datamodel.DatabaseHelper import com.android.messaging.datamodel.DatabaseHelper.ConversationColumns @@ -70,7 +70,7 @@ private data class SeedVCards( @EntryPoint @InstallIn(SingletonComponent::class) private interface SeedSubscriptionsEntryPoint { - fun subscriptionsRepository(): ConversationSubscriptionsRepository + fun subscriptionsRepository(): SubscriptionsRepository } fun seedTestData(context: Context) { diff --git a/src/com/android/messaging/di/conversation/ConversationBindsModule.kt b/src/com/android/messaging/di/conversation/ConversationBindsModule.kt index 0282724e..a23f8d2d 100644 --- a/src/com/android/messaging/di/conversation/ConversationBindsModule.kt +++ b/src/com/android/messaging/di/conversation/ConversationBindsModule.kt @@ -14,8 +14,6 @@ import com.android.messaging.data.conversation.repository.ConversationDraftsRepo import com.android.messaging.data.conversation.repository.ConversationDraftsRepositoryImpl import com.android.messaging.data.conversation.repository.ConversationParticipantsRepository import com.android.messaging.data.conversation.repository.ConversationParticipantsRepositoryImpl -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 @@ -26,6 +24,8 @@ import com.android.messaging.data.media.repository.ConversationAttachmentsReposi 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.data.subscription.repository.SubscriptionsRepository +import com.android.messaging.data.subscription.repository.SubscriptionsRepositoryImpl import com.android.messaging.domain.contacts.usecase.IsReadContactsPermissionGranted import com.android.messaging.domain.contacts.usecase.IsReadContactsPermissionGrantedImpl import com.android.messaging.domain.conversation.usecase.action.CheckConversationActionRequirements @@ -188,9 +188,9 @@ internal abstract class ConversationBindsModule { @Binds @Reusable - abstract fun bindConversationSubscriptionsRepository( - impl: ConversationSubscriptionsRepositoryImpl, - ): ConversationSubscriptionsRepository + abstract fun bindSubscriptionsRepository( + impl: SubscriptionsRepositoryImpl, + ): SubscriptionsRepository @Binds @Reusable diff --git a/src/com/android/messaging/domain/conversation/usecase/draft/ResolveDraftAttachmentsWithinLimit.kt b/src/com/android/messaging/domain/conversation/usecase/draft/ResolveDraftAttachmentsWithinLimit.kt index a9f4d381..efb8f5fa 100644 --- a/src/com/android/messaging/domain/conversation/usecase/draft/ResolveDraftAttachmentsWithinLimit.kt +++ b/src/com/android/messaging/domain/conversation/usecase/draft/ResolveDraftAttachmentsWithinLimit.kt @@ -1,7 +1,7 @@ package com.android.messaging.domain.conversation.usecase.draft import com.android.messaging.data.conversation.model.draft.ConversationDraftAttachment -import com.android.messaging.data.conversation.repository.ConversationSubscriptionsRepository +import com.android.messaging.data.subscription.repository.SubscriptionsRepository import com.android.messaging.domain.conversation.usecase.draft.model.DraftAttachmentLimitResult import javax.inject.Inject @@ -13,7 +13,7 @@ internal interface ResolveDraftAttachmentsWithinLimit { } internal class ResolveDraftAttachmentsWithinLimitImpl @Inject constructor( - private val conversationSubscriptionsRepository: ConversationSubscriptionsRepository, + private val subscriptionsRepository: SubscriptionsRepository, ) : ResolveDraftAttachmentsWithinLimit { override operator fun invoke( @@ -23,7 +23,7 @@ internal class ResolveDraftAttachmentsWithinLimitImpl @Inject constructor( return resolveAttachmentsWithinLimit( currentAttachments = currentAttachments, attachmentsToAdd = attachmentsToAdd, - attachmentLimit = conversationSubscriptionsRepository.resolveAttachmentLimit(), + attachmentLimit = subscriptionsRepository.resolveAttachmentLimit(), ) } 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 7a1c12f2..7f513a83 100644 --- a/src/com/android/messaging/domain/conversation/usecase/draft/SendConversationDraft.kt +++ b/src/com/android/messaging/domain/conversation/usecase/draft/SendConversationDraft.kt @@ -4,8 +4,8 @@ import com.android.messaging.data.conversation.mapper.ConversationDraftMessageDa 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.ConversationSubscriptionsRepository import com.android.messaging.data.conversation.repository.ConversationsRepository +import com.android.messaging.data.subscription.repository.SubscriptionsRepository import com.android.messaging.datamodel.action.InsertNewMessageAction import com.android.messaging.datamodel.data.MessageData import com.android.messaging.datamodel.data.ParticipantData @@ -42,7 +42,7 @@ internal interface SendConversationDraft { internal class SendConversationDraftImpl @Inject constructor( private val conversationsRepository: ConversationsRepository, - private val conversationSubscriptionsRepository: ConversationSubscriptionsRepository, + private val subscriptionsRepository: SubscriptionsRepository, private val getConversationDraftSendProtocol: GetConversationDraftSendProtocol, private val conversationDraftMessageDataMapper: ConversationDraftMessageDataMapper, @param:IoDispatcher @@ -259,7 +259,7 @@ internal class SendConversationDraftImpl @Inject constructor( val attachments = message.parts.filter { part -> part.isAttachment } - if (attachments.size > conversationSubscriptionsRepository.resolveAttachmentLimit()) { + if (attachments.size > subscriptionsRepository.resolveAttachmentLimit()) { throw MessageLimitExceededException(conversationId = conversationId) } diff --git a/src/com/android/messaging/ui/conversation/ConversationTestTags.kt b/src/com/android/messaging/ui/conversation/ConversationTestTags.kt index 5f3b88b8..473a4e70 100644 --- a/src/com/android/messaging/ui/conversation/ConversationTestTags.kt +++ b/src/com/android/messaging/ui/conversation/ConversationTestTags.kt @@ -39,6 +39,8 @@ internal const val ADD_PARTICIPANTS_CONFIRM_BUTTON_TEST_TAG = "add_participants_ 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 NEW_CHAT_SIM_SELECTOR_CHIP_TEST_TAG = "new_chat_sim_selector_chip" +internal const val NEW_CHAT_SIM_SELECTOR_DROPDOWN_TEST_TAG = "new_chat_sim_selector_dropdown" 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" @@ -60,6 +62,10 @@ internal fun conversationSimSelectorItemTestTag(selfParticipantId: String): Stri return "conversation_sim_selector_item_$selfParticipantId" } +internal fun newChatSimSelectorItemTestTag(selfParticipantId: String): String { + return "new_chat_sim_selector_item_$selfParticipantId" +} + internal fun conversationMessageItemTestTag(messageId: String): String { return "conversation_message_item_$messageId" } diff --git a/src/com/android/messaging/ui/conversation/addparticipants/AddParticipantsScreen.kt b/src/com/android/messaging/ui/conversation/addparticipants/AddParticipantsScreen.kt index 9e2c9929..7d216809 100644 --- a/src/com/android/messaging/ui/conversation/addparticipants/AddParticipantsScreen.kt +++ b/src/com/android/messaging/ui/conversation/addparticipants/AddParticipantsScreen.kt @@ -29,11 +29,11 @@ import com.android.messaging.ui.conversation.addParticipantsContactDestinationRo 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.ui.conversation.recipientpicker.component.RecipientSelectionContent +import com.android.messaging.ui.conversation.recipientpicker.component.RecipientSelectionContentUiState +import com.android.messaging.ui.conversation.recipientpicker.component.RecipientSelectionPrimaryActionUiState +import com.android.messaging.ui.conversation.recipientpicker.component.RecipientSelectionRowDecorators +import com.android.messaging.ui.conversation.recipientpicker.component.RecipientSelectionStrings import com.android.messaging.util.UiUtils import kotlinx.collections.immutable.toImmutableSet diff --git a/src/com/android/messaging/ui/conversation/audio/delegate/ConversationAudioRecordingDelegate.kt b/src/com/android/messaging/ui/conversation/audio/delegate/ConversationAudioRecordingDelegate.kt index 2263e292..2aab1a19 100644 --- a/src/com/android/messaging/ui/conversation/audio/delegate/ConversationAudioRecordingDelegate.kt +++ b/src/com/android/messaging/ui/conversation/audio/delegate/ConversationAudioRecordingDelegate.kt @@ -5,8 +5,8 @@ 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.data.media.repository.ConversationAttachmentsRepository +import com.android.messaging.data.subscription.repository.SubscriptionsRepository import com.android.messaging.di.core.DefaultDispatcher import com.android.messaging.ui.conversation.audio.model.ConversationAudioRecordingPhase import com.android.messaging.ui.conversation.audio.model.ConversationAudioRecordingUiState @@ -51,7 +51,7 @@ internal interface ConversationAudioRecordingDelegate : internal class ConversationAudioRecordingDelegateImpl @Inject constructor( private val conversationAttachmentsRepository: ConversationAttachmentsRepository, - private val conversationSubscriptionsRepository: ConversationSubscriptionsRepository, + private val subscriptionsRepository: SubscriptionsRepository, private val conversationDraftDelegate: ConversationDraftDelegate, @param:DefaultDispatcher private val defaultDispatcher: CoroutineDispatcher, @@ -362,7 +362,7 @@ internal class ConversationAudioRecordingDelegateImpl @Inject constructor( private suspend fun startRecordingInBackground(selfParticipantId: String) { val resolvedMediaRecorder = LevelTrackingMediaRecorder() - val maxMessageSize = conversationSubscriptionsRepository + val maxMessageSize = subscriptionsRepository .resolveMaxMessageSize(selfParticipantId = selfParticipantId) .first() diff --git a/src/com/android/messaging/ui/conversation/composer/delegate/ConversationDraftEditorDelegate.kt b/src/com/android/messaging/ui/conversation/composer/delegate/ConversationDraftEditorDelegate.kt index 3124754c..9b19f6d1 100644 --- a/src/com/android/messaging/ui/conversation/composer/delegate/ConversationDraftEditorDelegate.kt +++ b/src/com/android/messaging/ui/conversation/composer/delegate/ConversationDraftEditorDelegate.kt @@ -3,7 +3,7 @@ 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.data.conversation.repository.ConversationSubscriptionsRepository +import com.android.messaging.data.subscription.repository.SubscriptionsRepository import com.android.messaging.domain.conversation.usecase.draft.ResolveConversationDraftSendProtocol import com.android.messaging.domain.conversation.usecase.draft.ResolveDraftAttachmentsWithinLimit import com.android.messaging.domain.conversation.usecase.draft.model.ConversationDraftSendProtocol @@ -82,7 +82,7 @@ internal interface ConversationDraftEditorDelegate { @OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) internal class ConversationDraftEditorDelegateImpl @Inject constructor( - private val conversationSubscriptionsRepository: ConversationSubscriptionsRepository, + private val subscriptionsRepository: SubscriptionsRepository, private val resolveConversationDraftSendProtocol: ResolveConversationDraftSendProtocol, private val resolveDraftAttachmentsWithinLimit: ResolveDraftAttachmentsWithinLimit, ) : ConversationDraftEditorDelegate { @@ -177,7 +177,7 @@ internal class ConversationDraftEditorDelegateImpl @Inject constructor( val currentAttachmentCount = currentDraftEditorState.effectiveDraft.attachments.size + currentDraftEditorState.pendingAttachments.size - return currentAttachmentCount < conversationSubscriptionsRepository.resolveAttachmentLimit() + return currentAttachmentCount < subscriptionsRepository.resolveAttachmentLimit() } override fun addPendingAttachment(pendingAttachment: ConversationDraftPendingAttachment) { diff --git a/src/com/android/messaging/ui/conversation/composer/mapper/ConversationComposerUiStateMapper.kt b/src/com/android/messaging/ui/conversation/composer/mapper/ConversationComposerUiStateMapper.kt index a3fae371..a3e73b63 100644 --- a/src/com/android/messaging/ui/conversation/composer/mapper/ConversationComposerUiStateMapper.kt +++ b/src/com/android/messaging/ui/conversation/composer/mapper/ConversationComposerUiStateMapper.kt @@ -2,7 +2,7 @@ package com.android.messaging.ui.conversation.composer.mapper import com.android.messaging.data.conversation.model.draft.ConversationDraft import com.android.messaging.data.conversation.model.metadata.ConversationComposerAvailability -import com.android.messaging.data.conversation.model.metadata.ConversationSubscription +import com.android.messaging.data.subscription.model.Subscription import com.android.messaging.datamodel.MessageTextStats import com.android.messaging.datamodel.data.ParticipantData import com.android.messaging.domain.conversation.usecase.draft.model.ConversationDraftSendProtocol @@ -22,7 +22,7 @@ internal interface ConversationComposerUiStateMapper { draftState: ConversationDraftState, attachments: ImmutableList, composerAvailability: ConversationComposerAvailability, - subscriptions: ImmutableList, + subscriptions: ImmutableList, ): ConversationComposerUiState } @@ -34,7 +34,7 @@ internal class ConversationComposerUiStateMapperImpl @Inject constructor() : draftState: ConversationDraftState, attachments: ImmutableList, composerAvailability: ConversationComposerAvailability, - subscriptions: ImmutableList, + subscriptions: ImmutableList, ): ConversationComposerUiState { val draft = draftState.draft val hasWorkingDraft = draft.hasContent @@ -125,7 +125,7 @@ internal class ConversationComposerUiStateMapperImpl @Inject constructor() : } private fun buildSimSelectorUiState( - subscriptions: ImmutableList, + subscriptions: ImmutableList, selfParticipantId: String, ): ConversationSimSelectorUiState { val selected = subscriptions diff --git a/src/com/android/messaging/ui/conversation/composer/model/ConversationSimSelectorUiState.kt b/src/com/android/messaging/ui/conversation/composer/model/ConversationSimSelectorUiState.kt index 968ed11a..33e2cb10 100644 --- a/src/com/android/messaging/ui/conversation/composer/model/ConversationSimSelectorUiState.kt +++ b/src/com/android/messaging/ui/conversation/composer/model/ConversationSimSelectorUiState.kt @@ -1,14 +1,14 @@ package com.android.messaging.ui.conversation.composer.model import androidx.compose.runtime.Immutable -import com.android.messaging.data.conversation.model.metadata.ConversationSubscription +import com.android.messaging.data.subscription.model.Subscription import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf @Immutable internal data class ConversationSimSelectorUiState( - val subscriptions: ImmutableList = persistentListOf(), - val selectedSubscription: ConversationSubscription? = null, + val subscriptions: ImmutableList = persistentListOf(), + val selectedSubscription: Subscription? = null, ) { val isAvailable: Boolean get() = subscriptions.size > 1 diff --git a/src/com/android/messaging/ui/conversation/composer/ui/ConversationSimAvatar.kt b/src/com/android/messaging/ui/conversation/composer/ui/ConversationSimAvatar.kt new file mode 100644 index 00000000..11615579 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/composer/ui/ConversationSimAvatar.kt @@ -0,0 +1,52 @@ +package com.android.messaging.ui.conversation.composer.ui + +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.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.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.android.messaging.data.subscription.model.Subscription + +internal val ConversationSimAvatarDefaultSize: Dp = 40.dp + +@Composable +internal fun ConversationSimAvatar( + subscription: Subscription, + modifier: Modifier = Modifier, + size: Dp = ConversationSimAvatarDefaultSize, +) { + Box( + modifier = modifier + .size(size = size) + .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 Subscription.resolveAccentColor(): Color { + return when (color) { + 0 -> MaterialTheme.colorScheme.primary + else -> Color(color = color) + } +} diff --git a/src/com/android/messaging/ui/conversation/composer/ui/ConversationSimSelectorSheet.kt b/src/com/android/messaging/ui/conversation/composer/ui/ConversationSimSelectorSheet.kt index f01fee1c..481d768a 100644 --- a/src/com/android/messaging/ui/conversation/composer/ui/ConversationSimSelectorSheet.kt +++ b/src/com/android/messaging/ui/conversation/composer/ui/ConversationSimSelectorSheet.kt @@ -1,8 +1,6 @@ package com.android.messaging.ui.conversation.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 @@ -10,9 +8,7 @@ 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 @@ -24,15 +20,12 @@ 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.data.subscription.model.Subscription 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 @@ -103,7 +96,7 @@ private fun ConversationSimSelectorSheetContent( @Composable private fun ConversationSimSelectorRow( - subscription: ConversationSubscription, + subscription: Subscription, isSelected: Boolean, onClick: () -> Unit, ) { @@ -154,34 +147,3 @@ private fun ConversationSimSelectorRow( } } } - -@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/entry/ConversationEntryViewModel.kt b/src/com/android/messaging/ui/conversation/entry/ConversationEntryViewModel.kt index d8f8451b..9a0089b5 100644 --- a/src/com/android/messaging/ui/conversation/entry/ConversationEntryViewModel.kt +++ b/src/com/android/messaging/ui/conversation/entry/ConversationEntryViewModel.kt @@ -5,17 +5,22 @@ 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.data.subscription.model.Subscription +import com.android.messaging.data.subscription.repository.SubscriptionsRepository import com.android.messaging.datamodel.data.MessageData +import com.android.messaging.datamodel.data.ParticipantData 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.composer.model.ConversationSimSelectorUiState 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.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.CoroutineDispatcher @@ -44,10 +49,14 @@ internal interface ConversationEntryScreenModel { fun onNewChatRecipientLongPressed(destination: String) fun onNewChatRecipientSelected(destination: String) + fun onSimSelected(selfParticipantId: String) + fun onDraftPayloadConsumed(conversationId: String) fun onScrollPositionConsumed(conversationId: String) + fun onPendingSelfParticipantIdConsumed(conversationId: String) + fun onStartupAttachmentConsumed(conversationId: String) fun navigateBack() @@ -61,6 +70,7 @@ internal const val RESOLVING_CONVERSATION_INDICATOR_DELAY_MILLIS = 200L @HiltViewModel internal class ConversationEntryViewModel @Inject constructor( private val conversationMessageDataDraftMapper: ConversationMessageDataDraftMapper, + private val subscriptionsRepository: SubscriptionsRepository, private val isConversationRecipientLimitExceeded: IsConversationRecipientLimitExceeded, private val resolveConversationId: ResolveConversationId, private val savedStateHandle: SavedStateHandle, @@ -80,6 +90,10 @@ internal class ConversationEntryViewModel @Inject constructor( override val effects = _effects.asSharedFlow() override val uiState = _uiState.asStateFlow() + init { + observeActiveSubscriptions() + } + override fun onCreateGroupRequested() { // Re-entering group creation should also abandon any in-flight resolution. cancelConversationResolution() @@ -146,6 +160,7 @@ internal class ConversationEntryViewModel @Inject constructor( resolveConversation( destinations = destinations, resolvingRecipientDestination = null, + selfParticipantId = selectedSelfParticipantId(), ) } } @@ -199,6 +214,7 @@ internal class ConversationEntryViewModel @Inject constructor( contentUri = launchRequest.startupAttachmentUri, contentType = launchRequest.startupAttachmentType, ), + simSelectorState = _uiState.value.simSelectorState, ), ) savedStateHandle[PENDING_DRAFT_DATA_KEY] = launchRequest.draftData @@ -216,6 +232,25 @@ internal class ConversationEntryViewModel @Inject constructor( resolveConversation( destinations = listOf(destination), resolvingRecipientDestination = destination, + selfParticipantId = selectedSelfParticipantId(), + ) + } + + override fun onSimSelected(selfParticipantId: String) { + val currentSimState = _uiState.value.simSelectorState + val selectedSubscription = currentSimState.subscriptions + .firstOrNull { it.selfParticipantId == selfParticipantId } + ?: return + + savedStateHandle[SIM_SELECTED_SELF_PARTICIPANT_ID_KEY] = selectedSubscription + .selfParticipantId + + updateUiState( + _uiState.value.copy( + simSelectorState = currentSimState.copy( + selectedSubscription = selectedSubscription, + ), + ), ) } @@ -263,18 +298,46 @@ internal class ConversationEntryViewModel @Inject constructor( } } + override fun onPendingSelfParticipantIdConsumed(conversationId: String) { + val currentUiState = _uiState.value + + val hasPendingSelfParticipantId = currentUiState.pendingSelfParticipantId != null + + if (currentUiState.conversationId == conversationId && hasPendingSelfParticipantId) { + updateUiState( + currentUiState.copy( + pendingSelfParticipantId = null, + ), + ) + } + } + override fun navigateBack() { cancelConversationResolution() _effects.tryEmit(ConversationEntryEffect.NavigateBack) } override fun navigateToConversation(conversationId: String) { + navigateToConversation( + conversationId = conversationId, + selfParticipantId = null, + ) + } + + private fun navigateToConversation( + conversationId: String, + selfParticipantId: String?, + ) { + val pendingSelfParticipantId = selfParticipantId + ?.takeUnless { it.isBlank() } + updateUiState( _uiState.value.copy( conversationId = conversationId, isCreatingGroup = false, isResolvingConversation = false, isResolvingConversationIndicatorVisible = false, + pendingSelfParticipantId = pendingSelfParticipantId, resolvingRecipientDestination = null, selectedGroupRecipientDestinations = persistentListOf(), ), @@ -328,6 +391,72 @@ internal class ConversationEntryViewModel @Inject constructor( ) } + private fun observeActiveSubscriptions() { + viewModelScope.launch { + subscriptionsRepository + .observeActiveSubscriptions() + .collect(::reconcileSimSelection) + } + } + + private fun reconcileSimSelection(subscriptions: ImmutableList) { + val persistedSelfParticipantId = savedStateHandle + .get(SIM_SELECTED_SELF_PARTICIPANT_ID_KEY) + + val resolvedSelection = resolveSimSelection( + subscriptions = subscriptions, + persistedSelfParticipantId = persistedSelfParticipantId, + ) + + savedStateHandle[SIM_SELECTED_SELF_PARTICIPANT_ID_KEY] = resolvedSelection + ?.selfParticipantId + + updateUiState( + _uiState.value.copy( + simSelectorState = ConversationSimSelectorUiState( + subscriptions = subscriptions, + selectedSubscription = resolvedSelection, + ), + ), + ) + } + + private fun resolveSimSelection( + subscriptions: ImmutableList, + persistedSelfParticipantId: String?, + ): Subscription? { + val persistedMatch = subscriptions.firstOrNull { subscription -> + subscription.selfParticipantId == persistedSelfParticipantId + } + + return when { + persistedMatch != null -> persistedMatch + else -> resolveDefaultSubscription(subscriptions) + } + } + + private fun resolveDefaultSubscription( + subscriptions: ImmutableList, + ): Subscription? { + val defaultSubId = subscriptionsRepository.getDefaultSmsSubscriptionId() + + val matchingBySubId = when { + defaultSubId == ParticipantData.DEFAULT_SELF_SUB_ID -> null + + else -> { + subscriptions.firstOrNull { subscription -> + subscription.subId == defaultSubId + } + } + } + + return matchingBySubId ?: subscriptions.firstOrNull() + } + + private fun selectedSelfParticipantId(): String? { + return _uiState.value.simSelectorState.selectedSubscription?.selfParticipantId + } + private fun clearConversationResolutionState() { updateUiState( _uiState.value.copy( @@ -415,6 +544,7 @@ internal class ConversationEntryViewModel @Inject constructor( private fun resolveConversation( destinations: List, resolvingRecipientDestination: String?, + selfParticipantId: String?, ) { resolveConversationJob = viewModelScope.launch(mainDispatcher) { startConversationResolution(resolvingRecipientDestination) @@ -422,8 +552,10 @@ internal class ConversationEntryViewModel @Inject constructor( val showIndicatorJob = launchDelayedResolutionIndicator() try { - resolveConversationId(destinations) - .let(::handleResolveConversationIdResult) + handleResolveConversationIdResult( + result = resolveConversationId(destinations), + selfParticipantId = selfParticipantId, + ) } finally { showIndicatorJob.cancel() resolveConversationJob = null @@ -438,10 +570,16 @@ internal class ConversationEntryViewModel @Inject constructor( } } - private fun handleResolveConversationIdResult(result: ResolveConversationIdResult) { + private fun handleResolveConversationIdResult( + result: ResolveConversationIdResult, + selfParticipantId: String?, + ) { when (result) { is ResolveConversationIdResult.Resolved -> { - navigateToConversation(conversationId = result.conversationId) + navigateToConversation( + conversationId = result.conversationId, + selfParticipantId = selfParticipantId, + ) } ResolveConversationIdResult.EmptyDestinations, @@ -501,5 +639,6 @@ internal class ConversationEntryViewModel @Inject constructor( private const val PROCESSED_LAUNCH_GENERATION_KEY = "processed_launch_generation" private const val SELECTED_GROUP_RECIPIENT_DESTINATIONS_KEY = "selected_group_recipient_destinations" + private const val SIM_SELECTED_SELF_PARTICIPANT_ID_KEY = "sim_selected_self_participant_id" } } diff --git a/src/com/android/messaging/ui/conversation/entry/NewChatScreen.kt b/src/com/android/messaging/ui/conversation/entry/NewChatScreen.kt index f8b144be..a97e0556 100644 --- a/src/com/android/messaging/ui/conversation/entry/NewChatScreen.kt +++ b/src/com/android/messaging/ui/conversation/entry/NewChatScreen.kt @@ -50,15 +50,17 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.android.messaging.R 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.composer.model.ConversationSimSelectorUiState import com.android.messaging.ui.conversation.newChatContactDestinationRowTestTag 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.component.RecipientSelectionContent +import com.android.messaging.ui.conversation.recipientpicker.component.RecipientSelectionContentUiState +import com.android.messaging.ui.conversation.recipientpicker.component.RecipientSelectionPrimaryActionUiState +import com.android.messaging.ui.conversation.recipientpicker.component.RecipientSelectionRowDecorators +import com.android.messaging.ui.conversation.recipientpicker.component.RecipientSelectionStrings +import com.android.messaging.ui.conversation.recipientpicker.component.simselector.NewChatSimSelectorRow import com.android.messaging.ui.conversation.recipientpicker.model.RecipientPickerUiState import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf @@ -77,9 +79,11 @@ internal fun NewChatScreen( onCreateGroupConfirmed: () -> Unit = {}, onCreateGroupRecipientClick: (String) -> Unit = {}, onNavigateBack: () -> Unit = {}, + onSimSelected: (String) -> Unit = {}, pickerModel: RecipientPickerModel = hiltViewModel(), resolvingRecipientDestination: String? = null, selectedGroupRecipientDestinations: ImmutableList = persistentListOf(), + simSelectorUiState: ConversationSimSelectorUiState = ConversationSimSelectorUiState(), ) { val uiState by pickerModel.uiState.collectAsStateWithLifecycle() val screenContainerColor = MaterialTheme.colorScheme.surfaceVariant @@ -116,6 +120,7 @@ internal fun NewChatScreen( .fillMaxSize() .padding(paddingValues = contentPadding), pickerUiState = uiState, + simSelectorUiState = simSelectorUiState, isCreatingGroup = isCreatingGroup, isResolvingConversation = isResolvingConversation, isResolvingConversationIndicatorVisible = isResolvingConversationIndicatorVisible, @@ -128,6 +133,7 @@ internal fun NewChatScreen( onCreateGroupClick = onCreateGroupClick, onCreateGroupConfirmed = onCreateGroupConfirmed, onCreateGroupRecipientClick = onCreateGroupRecipientClick, + onSimSelected = onSimSelected, ) } } @@ -135,6 +141,7 @@ internal fun NewChatScreen( @Composable private fun NewChatRecipientSelectionContent( pickerUiState: RecipientPickerUiState, + simSelectorUiState: ConversationSimSelectorUiState, isCreatingGroup: Boolean, isResolvingConversation: Boolean, isResolvingConversationIndicatorVisible: Boolean, @@ -147,6 +154,7 @@ private fun NewChatRecipientSelectionContent( onCreateGroupClick: () -> Unit, onCreateGroupConfirmed: () -> Unit, onCreateGroupRecipientClick: (String) -> Unit, + onSimSelected: (String) -> Unit, modifier: Modifier = Modifier, ) { RecipientSelectionContent( @@ -161,22 +169,10 @@ private fun NewChatRecipientSelectionContent( queryPrefixText = stringResource(id = R.string.new_chat_recipient_prefix), queryPlaceholderText = stringResource(id = R.string.new_chat_query_hint), ), - rowDecorators = RecipientSelectionRowDecorators( - recipientRowTestTag = { item -> - newChatContactRowTestTag(contactId = item.id) - }, - destinationRowTestTag = { item, destination -> - newChatContactDestinationRowTestTag( - contactId = item.id, - destination = destination, - ) - }, - showRecipientTrailingIndicator = { _, destination -> - !isCreatingGroup && - isResolvingConversationIndicatorVisible && - resolvingRecipientDestination == destination - }, - trailingIndicatorTestTag = NEW_CHAT_CONTACT_RESOLVING_INDICATOR_TEST_TAG, + rowDecorators = newChatRecipientSelectionRowDecorators( + isCreatingGroup = isCreatingGroup, + isResolvingConversationIndicatorVisible = isResolvingConversationIndicatorVisible, + resolvingRecipientDestination = resolvingRecipientDestination, ), onRecipientDestinationClick = { _, destination -> when { @@ -195,6 +191,12 @@ private fun NewChatRecipientSelectionContent( else -> onContactLongClick(destination) } }, + simSelectorSlot = { + NewChatSimSelectorRow( + uiState = simSelectorUiState, + onSimSelected = onSimSelected, + ) + }, topListContent = { NewChatRecipientSelectionTopListContent( isCreatingGroup = isCreatingGroup, @@ -204,6 +206,30 @@ private fun NewChatRecipientSelectionContent( ) } +private fun newChatRecipientSelectionRowDecorators( + isCreatingGroup: Boolean, + isResolvingConversationIndicatorVisible: Boolean, + resolvingRecipientDestination: String?, +): RecipientSelectionRowDecorators { + return RecipientSelectionRowDecorators( + recipientRowTestTag = { item -> + newChatContactRowTestTag(contactId = item.id) + }, + destinationRowTestTag = { item, destination -> + newChatContactDestinationRowTestTag( + contactId = item.id, + destination = destination, + ) + }, + showRecipientTrailingIndicator = { _, destination -> + !isCreatingGroup && + isResolvingConversationIndicatorVisible && + resolvingRecipientDestination == destination + }, + trailingIndicatorTestTag = NEW_CHAT_CONTACT_RESOLVING_INDICATOR_TEST_TAG, + ) +} + @Composable private fun newChatRecipientSelectionContentUiState( pickerUiState: RecipientPickerUiState, diff --git a/src/com/android/messaging/ui/conversation/entry/model/ConversationEntryUiState.kt b/src/com/android/messaging/ui/conversation/entry/model/ConversationEntryUiState.kt index ed87dca0..25989b76 100644 --- a/src/com/android/messaging/ui/conversation/entry/model/ConversationEntryUiState.kt +++ b/src/com/android/messaging/ui/conversation/entry/model/ConversationEntryUiState.kt @@ -2,6 +2,7 @@ package com.android.messaging.ui.conversation.entry.model import androidx.compose.runtime.Immutable import com.android.messaging.data.conversation.model.draft.ConversationDraft +import com.android.messaging.ui.conversation.composer.model.ConversationSimSelectorUiState import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf @@ -14,9 +15,11 @@ internal data class ConversationEntryUiState( val isResolvingConversationIndicatorVisible: Boolean = false, val pendingDraft: ConversationDraft? = null, val pendingScrollPosition: Int? = null, + val pendingSelfParticipantId: String? = null, val pendingStartupAttachment: ConversationEntryStartupAttachment? = null, val resolvingRecipientDestination: String? = null, val selectedGroupRecipientDestinations: ImmutableList = persistentListOf(), + val simSelectorState: ConversationSimSelectorUiState = ConversationSimSelectorUiState(), ) @Immutable 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 4791073e..5d43d898 100644 --- a/src/com/android/messaging/ui/conversation/messages/ui/ConversationMessages.kt +++ b/src/com/android/messaging/ui/conversation/messages/ui/ConversationMessages.kt @@ -23,7 +23,7 @@ import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.platform.testTag import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import com.android.messaging.data.conversation.model.metadata.ConversationSubscription +import com.android.messaging.data.subscription.model.Subscription 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 @@ -65,7 +65,7 @@ internal fun ConversationMessages( listState: LazyListState, selectedMessageIds: ImmutableSet = persistentSetOf(), showIncomingSenderLabels: Boolean = true, - subscriptions: ImmutableList = persistentListOf(), + subscriptions: ImmutableList = persistentListOf(), onAttachmentClick: (contentType: String, contentUri: String) -> Unit, onExternalUriClick: (String) -> Unit, onMessageClick: (String) -> Unit, diff --git a/src/com/android/messaging/ui/conversation/navigation/ConversationNavGraph.kt b/src/com/android/messaging/ui/conversation/navigation/ConversationNavGraph.kt index 4735dcd5..1653c549 100644 --- a/src/com/android/messaging/ui/conversation/navigation/ConversationNavGraph.kt +++ b/src/com/android/messaging/ui/conversation/navigation/ConversationNavGraph.kt @@ -141,6 +141,7 @@ private fun conversationScreenRouteContent( }, pendingDraft = pendingPayload.draft, pendingScrollPosition = pendingPayload.scrollPosition, + pendingSelfParticipantId = pendingPayload.selfParticipantId, pendingStartupAttachment = pendingPayload.startupAttachment, onPendingDraftConsumed = { entryModel.onDraftPayloadConsumed(conversationId = conversationId) @@ -148,6 +149,9 @@ private fun conversationScreenRouteContent( onPendingScrollPositionConsumed = { entryModel.onScrollPositionConsumed(conversationId = conversationId) }, + onPendingSelfParticipantIdConsumed = { + entryModel.onPendingSelfParticipantIdConsumed(conversationId = conversationId) + }, onPendingStartupAttachmentConsumed = { entryModel.onStartupAttachmentConsumed(conversationId = conversationId) }, @@ -181,8 +185,10 @@ private fun newChatRouteContent( onFinish = routeState.onFinish.value, ) }, + onSimSelected = entryModel::onSimSelected, resolvingRecipientDestination = entryUiState.resolvingRecipientDestination, selectedGroupRecipientDestinations = entryUiState.selectedGroupRecipientDestinations, + simSelectorUiState = entryUiState.simSelectorState, ) } } @@ -309,6 +315,7 @@ private fun pendingLaunchPayloadForConversation( return ConversationPendingLaunchPayload( draft = entryUiState.pendingDraft, scrollPosition = entryUiState.pendingScrollPosition, + selfParticipantId = entryUiState.pendingSelfParticipantId, startupAttachment = entryUiState.pendingStartupAttachment, ) } @@ -337,6 +344,7 @@ private data class ConversationNavEffectState( private data class ConversationPendingLaunchPayload( val draft: ConversationDraft? = null, val scrollPosition: Int? = null, + val selfParticipantId: String? = null, val startupAttachment: ConversationEntryStartupAttachment? = null, ) diff --git a/src/com/android/messaging/ui/conversation/recipientpicker/RecipientSelectionContactAvatar.kt b/src/com/android/messaging/ui/conversation/recipientpicker/component/RecipientSelectionContactAvatar.kt similarity index 98% rename from src/com/android/messaging/ui/conversation/recipientpicker/RecipientSelectionContactAvatar.kt rename to src/com/android/messaging/ui/conversation/recipientpicker/component/RecipientSelectionContactAvatar.kt index 8aeefaa0..3a71ca47 100644 --- a/src/com/android/messaging/ui/conversation/recipientpicker/RecipientSelectionContactAvatar.kt +++ b/src/com/android/messaging/ui/conversation/recipientpicker/component/RecipientSelectionContactAvatar.kt @@ -1,4 +1,4 @@ -package com.android.messaging.ui.conversation.recipientpicker +package com.android.messaging.ui.conversation.recipientpicker.component import androidx.compose.animation.AnimatedContent import androidx.compose.animation.core.Spring diff --git a/src/com/android/messaging/ui/conversation/recipientpicker/RecipientSelectionContactRow.kt b/src/com/android/messaging/ui/conversation/recipientpicker/component/RecipientSelectionContactRow.kt similarity index 99% rename from src/com/android/messaging/ui/conversation/recipientpicker/RecipientSelectionContactRow.kt rename to src/com/android/messaging/ui/conversation/recipientpicker/component/RecipientSelectionContactRow.kt index a701114e..301f862f 100644 --- a/src/com/android/messaging/ui/conversation/recipientpicker/RecipientSelectionContactRow.kt +++ b/src/com/android/messaging/ui/conversation/recipientpicker/component/RecipientSelectionContactRow.kt @@ -1,4 +1,4 @@ -package com.android.messaging.ui.conversation.recipientpicker +package com.android.messaging.ui.conversation.recipientpicker.component import android.provider.ContactsContract.CommonDataKinds.Email import android.provider.ContactsContract.CommonDataKinds.Phone diff --git a/src/com/android/messaging/ui/conversation/recipientpicker/RecipientSelectionContactsContent.kt b/src/com/android/messaging/ui/conversation/recipientpicker/component/RecipientSelectionContactsContent.kt similarity index 99% rename from src/com/android/messaging/ui/conversation/recipientpicker/RecipientSelectionContactsContent.kt rename to src/com/android/messaging/ui/conversation/recipientpicker/component/RecipientSelectionContactsContent.kt index 515f4535..043fb155 100644 --- a/src/com/android/messaging/ui/conversation/recipientpicker/RecipientSelectionContactsContent.kt +++ b/src/com/android/messaging/ui/conversation/recipientpicker/component/RecipientSelectionContactsContent.kt @@ -1,4 +1,4 @@ -package com.android.messaging.ui.conversation.recipientpicker +package com.android.messaging.ui.conversation.recipientpicker.component import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.EnterTransition diff --git a/src/com/android/messaging/ui/conversation/recipientpicker/RecipientSelectionContent.kt b/src/com/android/messaging/ui/conversation/recipientpicker/component/RecipientSelectionContent.kt similarity index 79% rename from src/com/android/messaging/ui/conversation/recipientpicker/RecipientSelectionContent.kt rename to src/com/android/messaging/ui/conversation/recipientpicker/component/RecipientSelectionContent.kt index fb61dd36..c4736f2a 100644 --- a/src/com/android/messaging/ui/conversation/recipientpicker/RecipientSelectionContent.kt +++ b/src/com/android/messaging/ui/conversation/recipientpicker/component/RecipientSelectionContent.kt @@ -2,7 +2,7 @@ ExperimentalMaterial3Api::class, ) -package com.android.messaging.ui.conversation.recipientpicker +package com.android.messaging.ui.conversation.recipientpicker.component import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer @@ -26,7 +26,7 @@ import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp -private val searchFieldShape = RoundedCornerShape(size = 22.dp) +private val searchCardShape = RoundedCornerShape(size = 22.dp) @Composable internal fun RecipientSelectionContent( @@ -40,6 +40,7 @@ internal fun RecipientSelectionContent( onPrimaryActionClick: () -> Unit = {}, onQueryChanged: (String) -> Unit = {}, onRecipientDestinationLongClick: OnRecipientDestinationAction? = null, + simSelectorSlot: (@Composable () -> Unit)? = null, topListContent: (@Composable () -> Unit)? = null, ) { val queryFocusRequester = remember { FocusRequester() } @@ -61,13 +62,14 @@ internal fun RecipientSelectionContent( ) { Spacer(modifier = Modifier.height(height = 16.dp)) - RecipientSelectionQueryField( + RecipientSelectionQueryCard( query = uiState.picker.query, enabled = uiState.isQueryEnabled, prefixText = strings.queryPrefixText, placeholderText = strings.queryPlaceholderText, onQueryChanged = onQueryChanged, focusRequester = queryFocusRequester, + simSelectorSlot = simSelectorSlot, ) Spacer(modifier = Modifier.height(height = 12.dp)) @@ -86,6 +88,36 @@ internal fun RecipientSelectionContent( } } +@Composable +private fun RecipientSelectionQueryCard( + query: String, + enabled: Boolean, + prefixText: String, + placeholderText: String, + onQueryChanged: (String) -> Unit, + focusRequester: FocusRequester, + simSelectorSlot: (@Composable () -> Unit)?, +) { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = searchCardShape, + color = MaterialTheme.colorScheme.surface, + ) { + Column(modifier = Modifier.fillMaxWidth()) { + RecipientSelectionQueryField( + query = query, + enabled = enabled, + prefixText = prefixText, + placeholderText = placeholderText, + onQueryChanged = onQueryChanged, + focusRequester = focusRequester, + ) + + simSelectorSlot?.invoke() + } + } +} + @Composable private fun RecipientSelectionQueryField( query: String, @@ -103,11 +135,10 @@ private fun RecipientSelectionQueryField( onValueChange = onQueryChanged, enabled = enabled, singleLine = true, - shape = searchFieldShape, colors = TextFieldDefaults.colors( - focusedContainerColor = MaterialTheme.colorScheme.surface, - unfocusedContainerColor = MaterialTheme.colorScheme.surface, - disabledContainerColor = MaterialTheme.colorScheme.surface, + focusedContainerColor = Color.Transparent, + unfocusedContainerColor = Color.Transparent, + disabledContainerColor = Color.Transparent, focusedIndicatorColor = Color.Transparent, unfocusedIndicatorColor = Color.Transparent, disabledIndicatorColor = Color.Transparent, diff --git a/src/com/android/messaging/ui/conversation/recipientpicker/RecipientSelectionContentUiState.kt b/src/com/android/messaging/ui/conversation/recipientpicker/component/RecipientSelectionContentUiState.kt similarity index 96% rename from src/com/android/messaging/ui/conversation/recipientpicker/RecipientSelectionContentUiState.kt rename to src/com/android/messaging/ui/conversation/recipientpicker/component/RecipientSelectionContentUiState.kt index 8053eef7..909e3072 100644 --- a/src/com/android/messaging/ui/conversation/recipientpicker/RecipientSelectionContentUiState.kt +++ b/src/com/android/messaging/ui/conversation/recipientpicker/component/RecipientSelectionContentUiState.kt @@ -1,4 +1,4 @@ -package com.android.messaging.ui.conversation.recipientpicker +package com.android.messaging.ui.conversation.recipientpicker.component import androidx.compose.runtime.Immutable import com.android.messaging.ui.conversation.recipientpicker.model.RecipientPickerListItem diff --git a/src/com/android/messaging/ui/conversation/recipientpicker/RecipientSelectionPrimaryActionButton.kt b/src/com/android/messaging/ui/conversation/recipientpicker/component/RecipientSelectionPrimaryActionButton.kt similarity index 98% rename from src/com/android/messaging/ui/conversation/recipientpicker/RecipientSelectionPrimaryActionButton.kt rename to src/com/android/messaging/ui/conversation/recipientpicker/component/RecipientSelectionPrimaryActionButton.kt index 33d1f7f2..de9add3a 100644 --- a/src/com/android/messaging/ui/conversation/recipientpicker/RecipientSelectionPrimaryActionButton.kt +++ b/src/com/android/messaging/ui/conversation/recipientpicker/component/RecipientSelectionPrimaryActionButton.kt @@ -1,4 +1,4 @@ -package com.android.messaging.ui.conversation.recipientpicker +package com.android.messaging.ui.conversation.recipientpicker.component import androidx.compose.animation.AnimatedContent import androidx.compose.animation.ContentTransform diff --git a/src/com/android/messaging/ui/conversation/recipientpicker/component/simselector/NewChatSimSelector.kt b/src/com/android/messaging/ui/conversation/recipientpicker/component/simselector/NewChatSimSelector.kt new file mode 100644 index 00000000..cd61303b --- /dev/null +++ b/src/com/android/messaging/ui/conversation/recipientpicker/component/simselector/NewChatSimSelector.kt @@ -0,0 +1,231 @@ +package com.android.messaging.ui.conversation.recipientpicker.component.simselector + +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.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.ArrowDropDown +import androidx.compose.material.icons.rounded.Check +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +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.clip +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import com.android.messaging.R +import com.android.messaging.data.subscription.model.Subscription +import com.android.messaging.ui.conversation.NEW_CHAT_SIM_SELECTOR_CHIP_TEST_TAG +import com.android.messaging.ui.conversation.NEW_CHAT_SIM_SELECTOR_DROPDOWN_TEST_TAG +import com.android.messaging.ui.conversation.composer.model.ConversationSimSelectorUiState +import com.android.messaging.ui.conversation.composer.ui.ConversationSimAvatar +import com.android.messaging.ui.conversation.newChatSimSelectorItemTestTag +import com.android.messaging.ui.conversation.resolveDisplayName +import kotlinx.collections.immutable.ImmutableList + +private val ChipShape = RoundedCornerShape(size = 16.dp) +private val ChipAvatarSize = 24.dp +private val DropdownAvatarSize = 32.dp + +@Composable +internal fun NewChatSimSelectorRow( + uiState: ConversationSimSelectorUiState, + onSimSelected: (String) -> Unit, + modifier: Modifier = Modifier, +) { + if (!uiState.isAvailable) { + return + } + + val selectedSubscription = uiState.selectedSubscription ?: return + + var isDropdownExpanded by rememberSaveable { mutableStateOf(value = false) } + + Row( + modifier = modifier + .fillMaxWidth() + .padding( + start = 16.dp, + end = 16.dp, + top = 4.dp, + bottom = 8.dp, + ), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(space = 12.dp), + ) { + Text( + text = stringResource(id = R.string.new_chat_sim_selector_prefix), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Box { + NewChatSimSelectorChip( + subscription = selectedSubscription, + onClick = { isDropdownExpanded = true }, + ) + + NewChatSimSelectorDropdown( + expanded = isDropdownExpanded, + subscriptions = uiState.subscriptions, + selectedSubscription = selectedSubscription, + onSimSelected = onSimSelected, + onDismissRequest = { isDropdownExpanded = false }, + ) + } + } +} + +@Composable +private fun NewChatSimSelectorChip( + subscription: Subscription, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val label = subscription.label.resolveDisplayName() + val chipDescription = stringResource( + id = R.string.new_chat_sim_selector_chip_content_description, + label, + ) + + Row( + modifier = modifier + .clip(shape = ChipShape) + .background(color = MaterialTheme.colorScheme.surfaceVariant) + .clickable(role = Role.Button, onClick = onClick) + .testTag(tag = NEW_CHAT_SIM_SELECTOR_CHIP_TEST_TAG) + .semantics { contentDescription = chipDescription } + .padding( + start = 6.dp, + end = 8.dp, + top = 4.dp, + bottom = 4.dp, + ), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(space = 8.dp), + ) { + ConversationSimAvatar( + subscription = subscription, + size = ChipAvatarSize, + ) + + Text( + text = label, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + + Icon( + imageVector = Icons.Rounded.ArrowDropDown, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +@Composable +private fun NewChatSimSelectorDropdown( + expanded: Boolean, + subscriptions: ImmutableList, + selectedSubscription: Subscription?, + onSimSelected: (String) -> Unit, + onDismissRequest: () -> Unit, + modifier: Modifier = Modifier, +) { + DropdownMenu( + modifier = modifier + .testTag(tag = NEW_CHAT_SIM_SELECTOR_DROPDOWN_TEST_TAG), + expanded = expanded, + onDismissRequest = onDismissRequest, + ) { + subscriptions.forEach { subscription -> + val isSelected = subscription.selfParticipantId == + selectedSubscription?.selfParticipantId + + NewChatSimSelectorDropdownItem( + subscription = subscription, + isSelected = isSelected, + onClick = { + onSimSelected(subscription.selfParticipantId) + onDismissRequest() + }, + ) + } + } +} + +@Composable +private fun NewChatSimSelectorDropdownItem( + subscription: Subscription, + isSelected: Boolean, + onClick: () -> Unit, +) { + DropdownMenuItem( + modifier = Modifier + .testTag( + tag = newChatSimSelectorItemTestTag( + selfParticipantId = subscription.selfParticipantId, + ), + ), + onClick = onClick, + leadingIcon = { + ConversationSimAvatar( + subscription = subscription, + size = DropdownAvatarSize, + ) + }, + text = { + Row( + modifier = Modifier + .size(width = 240.dp, height = DropdownAvatarSize), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(space = 12.dp), + ) { + 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, + ) + } + } + }, + ) +} diff --git a/src/com/android/messaging/ui/conversation/screen/ConversationScreen.kt b/src/com/android/messaging/ui/conversation/screen/ConversationScreen.kt index 2407ff07..3f5928ed 100644 --- a/src/com/android/messaging/ui/conversation/screen/ConversationScreen.kt +++ b/src/com/android/messaging/ui/conversation/screen/ConversationScreen.kt @@ -56,9 +56,11 @@ internal fun ConversationScreen( onNavigateBack: () -> Unit, pendingDraft: ConversationDraft? = null, pendingScrollPosition: Int? = null, + pendingSelfParticipantId: String? = null, pendingStartupAttachment: ConversationEntryStartupAttachment? = null, onPendingDraftConsumed: () -> Unit = {}, onPendingScrollPositionConsumed: () -> Unit = {}, + onPendingSelfParticipantIdConsumed: () -> Unit = {}, onPendingStartupAttachmentConsumed: () -> Unit = {}, screenModel: ConversationScreenModel = hiltViewModel(), ) { @@ -84,6 +86,7 @@ internal fun ConversationScreen( launchGeneration = launchGeneration, cancelIncomingNotification = cancelIncomingNotification, pendingDraft = pendingDraft, + pendingSelfParticipantId = pendingSelfParticipantId, pendingStartupAttachment = pendingStartupAttachment, scaffoldUiState = scaffoldUiState, snackbarHostState = snackbarHostState, @@ -92,6 +95,7 @@ internal fun ConversationScreen( screenModel = screenModel, onNavigateBack = onNavigateBack, onPendingDraftConsumed = onPendingDraftConsumed, + onPendingSelfParticipantIdConsumed = onPendingSelfParticipantIdConsumed, onPendingStartupAttachmentConsumed = onPendingStartupAttachmentConsumed, ) diff --git a/src/com/android/messaging/ui/conversation/screen/ConversationScreenRoute.kt b/src/com/android/messaging/ui/conversation/screen/ConversationScreenRoute.kt index 1fb2cfb8..c73c6b4b 100644 --- a/src/com/android/messaging/ui/conversation/screen/ConversationScreenRoute.kt +++ b/src/com/android/messaging/ui/conversation/screen/ConversationScreenRoute.kt @@ -107,6 +107,7 @@ internal fun ConversationScreenRouteEffects( launchGeneration: Int?, cancelIncomingNotification: Boolean, pendingDraft: ConversationDraft?, + pendingSelfParticipantId: String?, pendingStartupAttachment: ConversationEntryStartupAttachment?, scaffoldUiState: ConversationScreenScaffoldUiState, snackbarHostState: SnackbarHostState, @@ -115,15 +116,18 @@ internal fun ConversationScreenRouteEffects( screenModel: ConversationScreenModel, onNavigateBack: () -> Unit, onPendingDraftConsumed: () -> Unit, + onPendingSelfParticipantIdConsumed: () -> Unit, onPendingStartupAttachmentConsumed: () -> Unit, ) { ConversationPendingLaunchEffects( conversationId = conversationId, launchGeneration = launchGeneration, pendingDraft = pendingDraft, + pendingSelfParticipantId = pendingSelfParticipantId, pendingStartupAttachment = pendingStartupAttachment, screenModel = screenModel, onPendingDraftConsumed = onPendingDraftConsumed, + onPendingSelfParticipantIdConsumed = onPendingSelfParticipantIdConsumed, onPendingStartupAttachmentConsumed = onPendingStartupAttachmentConsumed, ) @@ -154,9 +158,11 @@ private fun ConversationPendingLaunchEffects( conversationId: String?, launchGeneration: Int?, pendingDraft: ConversationDraft?, + pendingSelfParticipantId: String?, pendingStartupAttachment: ConversationEntryStartupAttachment?, screenModel: ConversationScreenModel, onPendingDraftConsumed: () -> Unit, + onPendingSelfParticipantIdConsumed: () -> Unit, onPendingStartupAttachmentConsumed: () -> Unit, ) { LaunchedEffect(conversationId, screenModel) { @@ -173,6 +179,22 @@ private fun ConversationPendingLaunchEffects( } } + LaunchedEffect( + conversationId, + launchGeneration, + pendingSelfParticipantId, + screenModel, + ) { + if ( + conversationId != null && + launchGeneration != null && + pendingSelfParticipantId != null + ) { + screenModel.onSimSelected(selfParticipantId = pendingSelfParticipantId) + onPendingSelfParticipantIdConsumed() + } + } + LaunchedEffect( conversationId, launchGeneration, diff --git a/src/com/android/messaging/ui/conversation/screen/ConversationViewModel.kt b/src/com/android/messaging/ui/conversation/screen/ConversationViewModel.kt index d31bad65..902b3dd0 100644 --- a/src/com/android/messaging/ui/conversation/screen/ConversationViewModel.kt +++ b/src/com/android/messaging/ui/conversation/screen/ConversationViewModel.kt @@ -6,8 +6,8 @@ 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.ConversationCapturedMedia +import com.android.messaging.data.subscription.repository.SubscriptionsRepository import com.android.messaging.datamodel.MessagingContentProvider import com.android.messaging.di.core.DefaultDispatcher import com.android.messaging.domain.conversation.usecase.action.CreateDefaultSmsRoleRequest @@ -139,7 +139,7 @@ internal class ConversationViewModel @Inject constructor( private val conversationMetadataDelegate: ConversationMetadataDelegate, private val conversationFocusDelegate: ConversationFocusDelegate, private val conversationComposerUiStateMapper: ConversationComposerUiStateMapper, - private val conversationSubscriptionsRepository: ConversationSubscriptionsRepository, + private val subscriptionsRepository: SubscriptionsRepository, private val canAddMoreConversationParticipants: CanAddMoreConversationParticipants, private val createDefaultSmsRoleRequest: CreateDefaultSmsRoleRequest, private val isDeviceVoiceCapable: IsDeviceVoiceCapable, @@ -160,7 +160,7 @@ internal class ConversationViewModel @Inject constructor( override val effects = _effects.asSharedFlow() - private val subscriptionsFlow = conversationSubscriptionsRepository + private val subscriptionsFlow = subscriptionsRepository .observeActiveSubscriptions() .stateIn( scope = viewModelScope, From 621489064ba0c8b7f70fc79f7164506e9b042012 Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Tue, 12 May 2026 22:06:09 +0300 Subject: [PATCH 113/136] Fix YetToSend status mapping --- .../ui/conversation/messages/ui/message/ConversationMessage.kt | 1 + 1 file changed, 1 insertion(+) 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 76be2e6d..6b109c28 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 @@ -366,6 +366,7 @@ private fun buildTimestampMetadataText( private fun messageStatusTextResourceId(status: Status): Int? { return when (status) { Status.Outgoing.Delivered -> R.string.delivered_status_content_description + Status.Outgoing.YetToSend -> R.string.message_status_sending 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 From f4b1b31fbb037f5df0bd7c0e16188ec53c96ae1c Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Tue, 12 May 2026 22:18:12 +0300 Subject: [PATCH 114/136] Add sending message announcement and sound --- .../delegate/ConversationDraftDelegate.kt | 1 + .../screen/ConversationAttachmentEffects.kt | 195 ++++++++++++++++ .../screen/ConversationScreenEffects.kt | 221 ++++-------------- .../screen/model/ConversationScreenEffect.kt | 2 + 4 files changed, 246 insertions(+), 173 deletions(-) create mode 100644 src/com/android/messaging/ui/conversation/screen/ConversationAttachmentEffects.kt 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 7768ed16..3d4cc66d 100644 --- a/src/com/android/messaging/ui/conversation/composer/delegate/ConversationDraftDelegate.kt +++ b/src/com/android/messaging/ui/conversation/composer/delegate/ConversationDraftDelegate.kt @@ -491,6 +491,7 @@ internal class ConversationDraftDelegateImpl @Inject constructor( sendRequest = sendRequest, ) didClearDraftAfterSend = true + _effects.emit(ConversationScreenEffect.NotifyDraftSent) }.onCompletion { throwable -> if (throwable != null || !didClearDraftAfterSend) { conversationDraftEditorDelegate.markConversationDraftAsIdle( diff --git a/src/com/android/messaging/ui/conversation/screen/ConversationAttachmentEffects.kt b/src/com/android/messaging/ui/conversation/screen/ConversationAttachmentEffects.kt new file mode 100644 index 00000000..95f19144 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/screen/ConversationAttachmentEffects.kt @@ -0,0 +1,195 @@ +package com.android.messaging.ui.conversation.screen + +import android.content.ContentResolver +import android.content.Context +import android.content.Intent +import android.graphics.Rect +import android.net.Uri +import androidx.compose.runtime.State +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.geometry.Rect as ComposeRect +import androidx.core.net.toUri +import com.android.messaging.R +import com.android.messaging.ui.UIIntents +import com.android.messaging.ui.conversation.screen.model.ConversationScreenEffect +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 + +internal 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() + }, + ) +} + +internal 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?, + 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, + hostBounds = resolvedHostBounds, + 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, + hostBounds: ComposeRect, + attachmentUri: Uri, + imageCollectionUri: String?, +): Boolean { + val activity = UiUtils.getActivity(context) + val imageCollection = imageCollectionUri?.toUri() + + if (activity == null || imageCollection == null) { + return false + } + + UIIntents.get().launchFullScreenPhotoViewer( + activity, + attachmentUri, + hostBounds.toAndroidRect(), + imageCollection, + ) + + return true +} + +private fun openGenericAttachmentPreview( + context: Context, + attachmentUri: Uri, + contentType: String, +) { + runCatching { + 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 + } + } + } +} + +private fun ComposeRect.toAndroidRect(): Rect { + return Rect( + left.roundToInt(), + top.roundToInt(), + right.roundToInt(), + bottom.roundToInt(), + ) +} diff --git a/src/com/android/messaging/ui/conversation/screen/ConversationScreenEffects.kt b/src/com/android/messaging/ui/conversation/screen/ConversationScreenEffects.kt index 421d5863..1e721194 100644 --- a/src/com/android/messaging/ui/conversation/screen/ConversationScreenEffects.kt +++ b/src/com/android/messaging/ui/conversation/screen/ConversationScreenEffects.kt @@ -1,37 +1,39 @@ package com.android.messaging.ui.conversation.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.foundation.layout.Box +import androidx.compose.foundation.layout.size 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 -import androidx.compose.runtime.snapshotFlow +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Rect as ComposeRect import androidx.compose.ui.platform.LocalContext -import androidx.core.net.toUri +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.LiveRegionMode +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.liveRegion +import androidx.compose.ui.unit.dp import com.android.messaging.R import com.android.messaging.ui.UIIntents import com.android.messaging.ui.conversation.MessageDetailsDialog import com.android.messaging.ui.conversation.screen.model.ConversationScreenEffect -import com.android.messaging.util.ContentType +import com.android.messaging.util.BuglePrefs import com.android.messaging.util.LogUtil +import com.android.messaging.util.MediaUtil 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 private const val LOG_TAG = "ConversationScreenEffects" @@ -48,6 +50,7 @@ internal fun ConversationScreenEffects( ) { result -> screenModel.onDefaultSmsRoleRequestResult(resultCode = result.resultCode) } + val draftSentTick = remember { mutableIntStateOf(0) } LaunchedEffect(screenModel, context, snackbarHostState, hostBoundsState, onNavigateBack) { screenModel.effects.collect { effect -> @@ -58,9 +61,12 @@ internal fun ConversationScreenEffects( effect = effect, launchRoleRequest = defaultSmsRoleLauncher::launch, onNavigateBack = onNavigateBack, + onDraftSent = { draftSentTick.intValue++ }, ) } } + + SendingMessageAnnouncement(triggerKey = draftSentTick.intValue) } private suspend fun ConversationScreenModel.handleConversationScreenEffect( @@ -70,6 +76,7 @@ private suspend fun ConversationScreenModel.handleConversationScreenEffect( effect: ConversationScreenEffect, launchRoleRequest: (Intent) -> Unit, onNavigateBack: () -> Unit, + onDraftSent: () -> Unit, ) { when (effect) { ConversationScreenEffect.CloseConversation -> onNavigateBack() @@ -109,6 +116,7 @@ private suspend fun ConversationScreenModel.handleConversationScreenEffect( is ConversationScreenEffect.LaunchAddContactFlow, is ConversationScreenEffect.LaunchForwardMessage, + ConversationScreenEffect.NotifyDraftSent, is ConversationScreenEffect.OpenExternalUri, is ConversationScreenEffect.PlacePhoneCall, is ConversationScreenEffect.ShowMessage, @@ -118,33 +126,16 @@ private suspend fun ConversationScreenModel.handleConversationScreenEffect( handleImmediateConversationScreenEffect( context = context, effect = effect, + onDraftSent = onDraftSent, ) } } } -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, + onDraftSent: () -> Unit, ) { when (effect) { is ConversationScreenEffect.LaunchAddContactFlow -> { @@ -161,6 +152,11 @@ private fun handleImmediateConversationScreenEffect( ) } + ConversationScreenEffect.NotifyDraftSent -> { + playDraftSentSound(context = context) + onDraftSent() + } + is ConversationScreenEffect.OpenExternalUri -> { openExternalUri( context = context, @@ -291,155 +287,34 @@ private fun showSaveAttachmentsResultToast( ) } -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?, - contentUri: String, - contentType: String, - imageCollectionUri: String?, - awaitHostBounds: suspend () -> ComposeRect, +@Composable +private fun SendingMessageAnnouncement( + triggerKey: Int, ) { - val attachmentUri = contentUri.toUri() - - when { - ContentType.isImageType(contentType) -> { - val resolvedHostBounds = hostBounds ?: awaitHostBounds() - val isOpenedInternally = openImageAttachmentPreview( - context = context, - hostBounds = resolvedHostBounds, - 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, - hostBounds: ComposeRect, - attachmentUri: Uri, - imageCollectionUri: String?, -): Boolean { - val activity = UiUtils.getActivity(context) - val imageCollection = imageCollectionUri?.toUri() - - if (activity == null || imageCollection == null) { - return false + if (triggerKey == 0) { + return } - UIIntents.get().launchFullScreenPhotoViewer( - activity, - attachmentUri, - hostBounds.toAndroidRect(), - imageCollection, - ) + val text = stringResource(R.string.sending_message) - return true -} - -private fun openGenericAttachmentPreview( - context: Context, - attachmentUri: Uri, - contentType: String, -) { - runCatching { - 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) + key(triggerKey) { + Box( + modifier = Modifier + .size(0.dp) + .clearAndSetSemantics { + liveRegion = LiveRegionMode.Polite + contentDescription = text + }, + ) } } -private suspend fun normalizeAttachmentUriForIntent( - attachmentUri: Uri, -): Uri { - return when { - attachmentUri.scheme != ContentResolver.SCHEME_FILE -> attachmentUri +private fun playDraftSentSound(context: Context) { + val prefs = BuglePrefs.getApplicationPrefs() + val prefKey = context.getString(R.string.send_sound_pref_key) + val default = context.resources.getBoolean(R.bool.send_sound_pref_default) - else -> { - withContext(context = Dispatchers.IO) { - UriUtil.persistContentToScratchSpace(attachmentUri) ?: attachmentUri - } - } + if (prefs.getBoolean(prefKey, default)) { + MediaUtil.get().playSound(context, R.raw.message_sent, null) } } - -private fun ComposeRect.toAndroidRect(): Rect { - return Rect( - left.roundToInt(), - top.roundToInt(), - right.roundToInt(), - bottom.roundToInt(), - ) -} diff --git a/src/com/android/messaging/ui/conversation/screen/model/ConversationScreenEffect.kt b/src/com/android/messaging/ui/conversation/screen/model/ConversationScreenEffect.kt index 677fc61c..2720f8a5 100644 --- a/src/com/android/messaging/ui/conversation/screen/model/ConversationScreenEffect.kt +++ b/src/com/android/messaging/ui/conversation/screen/model/ConversationScreenEffect.kt @@ -25,6 +25,8 @@ internal sealed interface ConversationScreenEffect { val message: MessageData, ) : ConversationScreenEffect + data object NotifyDraftSent : ConversationScreenEffect + data class OpenAttachmentPreview( val contentType: String, val contentUri: String, From c6773d9117f40f9c0bee6dafc03873d9aee028f6 Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Wed, 13 May 2026 20:52:50 +0300 Subject: [PATCH 115/136] MMS downloading UI --- .../ConversationMessageLinkLongClickTest.kt | 1 + ...veConversationMessageSimDisplayNameTest.kt | 29 +++ .../android/messaging/debug/TestDataSeeder.kt | 152 ++++++++++++ .../ConversationMessageSelectionDelegate.kt | 6 + .../ConversationMessageUiModelMapper.kt | 36 +++ .../message/ConversationMessageUiModel.kt | 1 + .../model/message/MmsDownloadUiModel.kt | 17 ++ .../messages/ui/ConversationMessages.kt | 33 ++- .../ui/message/ConversationMessage.kt | 18 +- .../ui/message/ConversationMessageBubble.kt | 38 ++- .../ui/message/ConversationMessageMetadata.kt | 6 +- .../ui/message/ConversationMessageRows.kt | 90 ++++--- .../ConversationMessageSimAnnotation.kt | 1 + .../ui/message/ConversationMmsDownloadBody.kt | 224 ++++++++++++++++++ .../conversation/screen/ConversationScreen.kt | 3 + .../screen/ConversationViewModel.kt | 5 + 16 files changed, 602 insertions(+), 58 deletions(-) create mode 100644 src/com/android/messaging/ui/conversation/messages/model/message/MmsDownloadUiModel.kt create mode 100644 src/com/android/messaging/ui/conversation/messages/ui/message/ConversationMmsDownloadBody.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 index 19729363..ce0d64e2 100644 --- 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 @@ -168,6 +168,7 @@ private fun outgoingMessage(text: String): ConversationMessageUiModel { canForwardMessage = true, canResendMessage = false, canSaveAttachments = false, + mmsDownload = null, mmsSubject = null, protocol = ConversationMessageUiModel.Protocol.SMS, ) diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/ui/message/ResolveConversationMessageSimDisplayNameTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/ui/message/ResolveConversationMessageSimDisplayNameTest.kt index 1d475fea..7d40f667 100644 --- a/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/ui/message/ResolveConversationMessageSimDisplayNameTest.kt +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/ui/message/ResolveConversationMessageSimDisplayNameTest.kt @@ -2,6 +2,7 @@ package com.android.messaging.ui.conversation.messages.ui.message 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.MmsDownloadUiModel import org.junit.Assert.assertEquals import org.junit.Assert.assertNull import org.junit.Test @@ -111,11 +112,30 @@ class ResolveConversationMessageSimDisplayNameTest { assertNull(result) } + + @Test + fun returnsDisplayNameForMmsDownloadInsideContiguousSimRun() { + val message = message( + selfParticipantId = SIM_1_ID, + isIncoming = true, + mmsDownload = mmsDownload(), + ) + val below = message(selfParticipantId = SIM_1_ID, isIncoming = true) + + val result = resolveConversationMessageSimDisplayName( + message = message, + messageBelow = below, + simDisplayNameByParticipantId = SIM_DISPLAY_NAMES, + ) + + assertEquals(SIM_1_NAME, result) + } } private fun message( selfParticipantId: String?, isIncoming: Boolean, + mmsDownload: MmsDownloadUiModel? = null, ): ConversationMessageUiModel { return ConversationMessageUiModel( messageId = "id-${selfParticipantId.orEmpty()}-$isIncoming", @@ -140,7 +160,16 @@ private fun message( canForwardMessage = false, canResendMessage = false, canSaveAttachments = false, + mmsDownload = mmsDownload, mmsSubject = null, protocol = ConversationMessageUiModel.Protocol.SMS, ) } + +private fun mmsDownload(): MmsDownloadUiModel { + return MmsDownloadUiModel( + state = MmsDownloadUiModel.State.AwaitingManualDownload, + sizeBytes = 0L, + expiryTimestamp = 0L, + ) +} diff --git a/src/com/android/messaging/debug/TestDataSeeder.kt b/src/com/android/messaging/debug/TestDataSeeder.kt index 0aa37c60..3dc7b2a4 100644 --- a/src/com/android/messaging/debug/TestDataSeeder.kt +++ b/src/com/android/messaging/debug/TestDataSeeder.kt @@ -107,6 +107,7 @@ fun seedTestData(context: Context) { val mia = upsertParticipant(db, "${TEST_PHONE_PREFIX}013456", "Mia Miller", "Mia") val noah = upsertParticipant(db, "${TEST_PHONE_PREFIX}014567", "Noah Nguyen", "Noah") val olivia = upsertParticipant(db, "${TEST_PHONE_PREFIX}015678", "Olivia Ortega", "Olivia") + val nora = upsertParticipant(db, "${TEST_PHONE_PREFIX}016789", "Nora Notifications", "Nora") seedScenarioA(db, selfId, alice, now) seedScenarioB(db, selfId, bob, now) @@ -139,6 +140,13 @@ fun seedTestData(context: Context) { now = now, ) } + seedScenarioM( + db = db, + realSelfId = selfId, + secondarySelfId = simBId ?: selfId, + noraId = nora, + now = now, + ) } MessagingContentProvider.notifyConversationListChanged() @@ -823,6 +831,55 @@ private fun insertMessageRow( }, ) +private fun insertMmsDownloadMessage( + db: DatabaseWrapper, + conversationId: Long, + senderId: String, + selfId: String, + status: Int, + timestamp: Long, + messageSizeBytes: Long, + expiryTimestamp: Long, + seedIndex: Int, +): Long { + val messageId = db.insert( + DatabaseHelper.MESSAGES_TABLE, + null, + ContentValues().apply { + put(MessageColumns.CONVERSATION_ID, conversationId) + put(MessageColumns.SENDER_PARTICIPANT_ID, senderId) + put(MessageColumns.SELF_PARTICIPANT_ID, selfId) + put(MessageColumns.STATUS, status) + put(MessageColumns.PROTOCOL, MessageData.PROTOCOL_MMS_PUSH_NOTIFICATION) + put(MessageColumns.SENT_TIMESTAMP, timestamp) + put(MessageColumns.RECEIVED_TIMESTAMP, timestamp) + put(MessageColumns.SEEN, 1) + put(MessageColumns.READ, 1) + put(MessageColumns.SMS_MESSAGE_URI, "content://mms/${900_000 + seedIndex}") + put(MessageColumns.SMS_PRIORITY, 0) + put(MessageColumns.SMS_MESSAGE_SIZE, messageSizeBytes) + put(MessageColumns.MMS_TRANSACTION_ID, "seeded-transaction-$seedIndex") + put(MessageColumns.MMS_CONTENT_LOCATION, "https://example.invalid/mms/$seedIndex") + put(MessageColumns.MMS_EXPIRY, expiryTimestamp) + put(MessageColumns.RAW_TELEPHONY_STATUS, MessageData.RAW_TELEPHONY_STATUS_UNDEFINED) + put(MessageColumns.RETRY_START_TIMESTAMP, timestamp) + }, + ) + + db.insert( + DatabaseHelper.PARTS_TABLE, + null, + ContentValues().apply { + put(PartColumns.MESSAGE_ID, messageId) + put(PartColumns.CONVERSATION_ID, conversationId) + put(PartColumns.CONTENT_TYPE, ContentType.TEXT_PLAIN) + put(PartColumns.TEXT, "") + }, + ) + + return messageId +} + private fun finalizeConversation( db: DatabaseWrapper, conversationId: Long, @@ -1728,6 +1785,101 @@ private fun seedScenarioL( finalizeConversation(db, convId, latestMsgId, latestTime, latestText) } +/** + * 1:1 MMS notification thread covering manual download rendering states. + * + * The last two rows intentionally mirror the regression case: the upper placeholder remains + * actionable while only the lower placeholder is in a downloading state. + */ +private fun seedScenarioM( + db: DatabaseWrapper, + realSelfId: String, + secondarySelfId: String, + noraId: String, + now: Long, +) { + val baseTime = now - 40 * MINUTES + val conversationId = createConversation( + db = db, + name = "MMS Download States", + selfId = realSelfId, + participantIds = listOf(noraId), + sortTimestamp = baseTime, + ) + + data class MmsDownloadSeedMessage( + val status: Int, + val selfId: String, + val offsetMillis: Long, + val sizeBytes: Long, + val expiryTimestamp: Long, + val snippet: String, + ) + + val messages = listOf( + MmsDownloadSeedMessage( + status = MessageData.BUGLE_STATUS_INCOMING_DOWNLOAD_FAILED, + selfId = realSelfId, + offsetMillis = 0L, + sizeBytes = 4_096L, + expiryTimestamp = now + 2 * DAYS, + snippet = "Couldn't download MMS", + ), + MmsDownloadSeedMessage( + status = MessageData.BUGLE_STATUS_INCOMING_EXPIRED_OR_NOT_AVAILABLE, + selfId = realSelfId, + offsetMillis = 8 * MINUTES, + sizeBytes = 9_216L, + expiryTimestamp = now - 1 * DAYS, + snippet = "MMS expired", + ), + MmsDownloadSeedMessage( + status = MessageData.BUGLE_STATUS_INCOMING_YET_TO_MANUAL_DOWNLOAD, + selfId = realSelfId, + offsetMillis = 16 * MINUTES, + sizeBytes = 1_548L, + expiryTimestamp = now + 3 * DAYS, + snippet = "Tap to download MMS", + ), + MmsDownloadSeedMessage( + status = MessageData.BUGLE_STATUS_INCOMING_MANUAL_DOWNLOADING, + selfId = secondarySelfId, + offsetMillis = 17 * MINUTES, + sizeBytes = 1_548L, + expiryTimestamp = now + 3 * DAYS, + snippet = "Downloading MMS", + ), + ) + + var latestMessageId = 0L + var latestTime = baseTime + var latestSnippet = "" + for ((index, message) in messages.withIndex()) { + val messageTime = baseTime + message.offsetMillis + latestMessageId = insertMmsDownloadMessage( + db = db, + conversationId = conversationId, + senderId = noraId, + selfId = message.selfId, + status = message.status, + timestamp = messageTime, + messageSizeBytes = message.sizeBytes, + expiryTimestamp = message.expiryTimestamp, + seedIndex = index + 1, + ) + latestTime = messageTime + latestSnippet = message.snippet + } + + finalizeConversation( + db = db, + conversationId = conversationId, + latestMessageId = latestMessageId, + latestTimestamp = latestTime, + snippetText = latestSnippet, + ) +} + /** * Group MMS thread covering subject combined with sender-display variations. * diff --git a/src/com/android/messaging/ui/conversation/messages/delegate/ConversationMessageSelectionDelegate.kt b/src/com/android/messaging/ui/conversation/messages/delegate/ConversationMessageSelectionDelegate.kt index eece0cee..aab52801 100644 --- a/src/com/android/messaging/ui/conversation/messages/delegate/ConversationMessageSelectionDelegate.kt +++ b/src/com/android/messaging/ui/conversation/messages/delegate/ConversationMessageSelectionDelegate.kt @@ -41,6 +41,8 @@ internal interface ConversationMessageSelectionDelegate : fun onMessageClick(messageId: String) + fun onMessageDownloadClick(messageId: String) + fun onMessageLongClick(messageId: String) fun onMessageResendClick(messageId: String) @@ -104,6 +106,10 @@ internal class ConversationMessageSelectionDelegateImpl @Inject constructor( } } + override fun onMessageDownloadClick(messageId: String) { + conversationsRepository.downloadMessage(messageId = messageId) + } + override fun onMessageLongClick(messageId: String) { toggleMessageSelection(messageId = messageId) } diff --git a/src/com/android/messaging/ui/conversation/messages/mapper/ConversationMessageUiModelMapper.kt b/src/com/android/messaging/ui/conversation/messages/mapper/ConversationMessageUiModelMapper.kt index cbf249f5..77c45eec 100644 --- a/src/com/android/messaging/ui/conversation/messages/mapper/ConversationMessageUiModelMapper.kt +++ b/src/com/android/messaging/ui/conversation/messages/mapper/ConversationMessageUiModelMapper.kt @@ -7,6 +7,7 @@ import com.android.messaging.ui.conversation.attachment.mapper.ConversationVCard 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.ui.conversation.messages.model.message.MmsDownloadUiModel import com.android.messaging.util.ContentType import com.android.messaging.util.LogUtil import javax.inject.Inject @@ -45,11 +46,46 @@ internal class ConversationMessageUiModelMapperImpl @Inject constructor( canForwardMessage = data.canForwardMessage, canResendMessage = data.showResendMessage, canSaveAttachments = canSaveAttachments(data), + mmsDownload = mapMmsDownload(data = data), mmsSubject = data.mmsSubject, protocol = mapProtocol(data), ) } + private fun mapMmsDownload(data: ConversationMessageData): MmsDownloadUiModel? { + val state = when (data.status) { + MessageData.BUGLE_STATUS_INCOMING_YET_TO_MANUAL_DOWNLOAD -> { + MmsDownloadUiModel.State.AwaitingManualDownload + } + + MessageData.BUGLE_STATUS_INCOMING_AUTO_DOWNLOADING, + MessageData.BUGLE_STATUS_INCOMING_MANUAL_DOWNLOADING, + MessageData.BUGLE_STATUS_INCOMING_RETRYING_AUTO_DOWNLOAD, + MessageData.BUGLE_STATUS_INCOMING_RETRYING_MANUAL_DOWNLOAD, + -> { + MmsDownloadUiModel.State.Downloading + } + + MessageData.BUGLE_STATUS_INCOMING_DOWNLOAD_FAILED -> { + MmsDownloadUiModel.State.DownloadFailed + } + + MessageData.BUGLE_STATUS_INCOMING_EXPIRED_OR_NOT_AVAILABLE -> { + MmsDownloadUiModel.State.ExpiredOrUnavailable + } + + else -> null + } + + return state?.let { + MmsDownloadUiModel( + state = state, + sizeBytes = data.smsMessageSize.toLong(), + expiryTimestamp = data.mmsExpiry, + ) + } + } + private fun canSaveAttachments(data: ConversationMessageData): Boolean { return when (val parts = data.parts) { null -> false diff --git a/src/com/android/messaging/ui/conversation/messages/model/message/ConversationMessageUiModel.kt b/src/com/android/messaging/ui/conversation/messages/model/message/ConversationMessageUiModel.kt index 4a821343..3feb774c 100644 --- a/src/com/android/messaging/ui/conversation/messages/model/message/ConversationMessageUiModel.kt +++ b/src/com/android/messaging/ui/conversation/messages/model/message/ConversationMessageUiModel.kt @@ -25,6 +25,7 @@ internal data class ConversationMessageUiModel( val canForwardMessage: Boolean, val canResendMessage: Boolean, val canSaveAttachments: Boolean, + val mmsDownload: MmsDownloadUiModel?, val mmsSubject: String?, val protocol: Protocol, ) { diff --git a/src/com/android/messaging/ui/conversation/messages/model/message/MmsDownloadUiModel.kt b/src/com/android/messaging/ui/conversation/messages/model/message/MmsDownloadUiModel.kt new file mode 100644 index 00000000..0f7289ef --- /dev/null +++ b/src/com/android/messaging/ui/conversation/messages/model/message/MmsDownloadUiModel.kt @@ -0,0 +1,17 @@ +package com.android.messaging.ui.conversation.messages.model.message + +import androidx.compose.runtime.Immutable + +@Immutable +internal data class MmsDownloadUiModel( + val state: State, + val sizeBytes: Long, + val expiryTimestamp: Long, +) { + enum class State { + AwaitingManualDownload, + Downloading, + DownloadFailed, + ExpiredOrUnavailable, + } +} 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 5d43d898..5ad8e73e 100644 --- a/src/com/android/messaging/ui/conversation/messages/ui/ConversationMessages.kt +++ b/src/com/android/messaging/ui/conversation/messages/ui/ConversationMessages.kt @@ -69,26 +69,21 @@ internal fun ConversationMessages( onAttachmentClick: (contentType: String, contentUri: String) -> Unit, onExternalUriClick: (String) -> Unit, onMessageClick: (String) -> Unit, + onMessageDownloadClick: (String) -> Unit, onMessageLongClick: (String) -> Unit, onMessageResendClick: (String) -> Unit, onSimSelectorClick: () -> Unit = {}, ) { val configuration = LocalConfiguration.current - val resources = LocalResources.current val displayMessages = remember(messages) { messages.asReversed() } val timeZone = remember(configuration) { TimeZone.getDefault() } - - val simDisplayNameByParticipantId = remember(subscriptions, resources) { - subscriptions.associate { subscription -> - subscription.selfParticipantId to subscription.label.resolveDisplayName( - resources = resources, - ) - } - } + val simDisplayNameByParticipantId = rememberSimDisplayNameByParticipantId( + subscriptions = subscriptions, + ) LazyColumn( state = listState, @@ -127,6 +122,7 @@ internal fun ConversationMessages( onAttachmentClick = onAttachmentClick, onExternalUriClick = onExternalUriClick, onMessageClick = onMessageClick, + onMessageDownloadClick = onMessageDownloadClick, onMessageLongClick = onMessageLongClick, onMessageResendClick = onMessageResendClick, onSimSelectorClick = onSimSelectorClick, @@ -135,6 +131,21 @@ internal fun ConversationMessages( } } +@Composable +private fun rememberSimDisplayNameByParticipantId( + subscriptions: ImmutableList, +): Map { + val resources = LocalResources.current + + return remember(subscriptions, resources) { + subscriptions.associate { subscription -> + subscription.selfParticipantId to subscription.label.resolveDisplayName( + resources = resources, + ) + } + } +} + @Immutable private data class ConversationMessagesItemPresentation( val showDateSeparator: Boolean, @@ -188,6 +199,7 @@ private fun ConversationMessagesItem( onAttachmentClick: (contentType: String, contentUri: String) -> Unit, onExternalUriClick: (String) -> Unit, onMessageClick: (String) -> Unit, + onMessageDownloadClick: (String) -> Unit, onMessageLongClick: (String) -> Unit, onMessageResendClick: (String) -> Unit, onSimSelectorClick: () -> Unit, @@ -223,6 +235,9 @@ private fun ConversationMessagesItem( onMessageClick = { onMessageClick(message.messageId) }, + onMessageDownloadClick = { + onMessageDownloadClick(message.messageId) + }, onMessageLongClick = { onMessageLongClick(message.messageId) }, 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 6b109c28..d460ce5b 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 @@ -41,6 +41,7 @@ internal fun ConversationMessage( onAttachmentClick: (contentType: String, contentUri: String) -> Unit = { _, _ -> }, onExternalUriClick: (String) -> Unit = {}, onMessageClick: () -> Unit = {}, + onMessageDownloadClick: () -> Unit = {}, onMessageLongClick: () -> Unit = {}, onMessageResendClick: () -> Unit = {}, onSimSelectorClick: () -> Unit = {}, @@ -72,6 +73,7 @@ internal fun ConversationMessage( onAttachmentClick = onAttachmentClick, onExternalUriClick = onExternalUriClick, onMessageClick = onMessageClick, + onMessageDownloadClick = onMessageDownloadClick, onMessageLongClick = onMessageLongClick, onMessageResendClick = onMessageResendClick, onSimSelectorClick = onSimSelectorClick, @@ -183,8 +185,13 @@ private fun rememberConversationMessageContent( private fun rememberConversationMessageMetadataText( message: ConversationMessageUiModel, ): String? { + if (message.mmsDownload != null) { + return null + } + val context = LocalContext.current val configuration = LocalConfiguration.current + val statusTextResourceId = remember(message.status) { messageStatusTextResourceId(status = message.status) } @@ -226,6 +233,7 @@ private fun ConversationMessageContent( onAttachmentClick: (contentType: String, contentUri: String) -> Unit, onExternalUriClick: (String) -> Unit, onMessageClick: () -> Unit, + onMessageDownloadClick: () -> Unit, onMessageLongClick: () -> Unit, onMessageResendClick: () -> Unit, onSimSelectorClick: () -> Unit, @@ -239,9 +247,11 @@ private fun ConversationMessageContent( isSelectionMode = isSelectionMode, layout = layout, maxBubbleWidth = maxBubbleWidth, + simDisplayName = simDisplayName, onAttachmentClick = onAttachmentClick, onExternalUriClick = onExternalUriClick, onMessageClick = onMessageClick, + onMessageDownloadClick = onMessageDownloadClick, onMessageLongClick = onMessageLongClick, onMessageResendClick = onMessageResendClick, ) @@ -362,7 +372,6 @@ private fun buildTimestampMetadataText( } } -@Suppress("CyclomaticComplexMethod") private fun messageStatusTextResourceId(status: Status): Int? { return when (status) { Status.Outgoing.Delivered -> R.string.delivered_status_content_description @@ -374,13 +383,6 @@ private fun messageStatusTextResourceId(status: Status): Int? { 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 - 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 } } 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 3e80ff76..4a3853e8 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 @@ -42,6 +42,7 @@ internal fun ConversationMessageBubble( isSelectionMode: Boolean, layout: ConversationMessageLayout, maxBubbleWidth: Dp, + simDisplayName: String?, onAttachmentClick: (contentType: String, contentUri: String) -> Unit, onExternalUriClick: (String) -> Unit, onMessageLongClick: () -> Unit, @@ -84,6 +85,7 @@ internal fun ConversationMessageBubble( isSelected = isSelected, message = message, isSelectionMode = isSelectionMode, + simDisplayName = simDisplayName, onAttachmentClick = onAttachmentClick, onExternalUriClick = onExternalUriClick, onMessageLongClick = onMessageLongClick, @@ -158,6 +160,7 @@ private fun ConversationMessageTextSurfaceBubble( isSelected: Boolean, message: ConversationMessageUiModel, isSelectionMode: Boolean, + simDisplayName: String?, onAttachmentClick: (contentType: String, contentUri: String) -> Unit, onExternalUriClick: (String) -> Unit, onMessageLongClick: () -> Unit, @@ -173,6 +176,7 @@ private fun ConversationMessageTextSurfaceBubble( message = message, isSelected = isSelected, isSelectionMode = isSelectionMode, + simDisplayName = simDisplayName, onAttachmentClick = onAttachmentClick, onExternalUriClick = onExternalUriClick, onMessageLongClick = onMessageLongClick, @@ -254,6 +258,7 @@ private fun ConversationMessageTextBubbleContent( message: ConversationMessageUiModel, isSelected: Boolean, isSelectionMode: Boolean, + simDisplayName: String?, onAttachmentClick: (contentType: String, contentUri: String) -> Unit, onExternalUriClick: (String) -> Unit, onMessageLongClick: () -> Unit, @@ -274,14 +279,31 @@ private fun ConversationMessageTextBubbleContent( showSender = layout.showSender, ) - ConversationMessageBody( - content = layout.content, - isIncoming = message.isIncoming, - isSelectionMode = isSelectionMode, - onAttachmentClick = onAttachmentClick, - onExternalUriClick = onExternalUriClick, - onMessageLongClick = onMessageLongClick, - ) + when { + message.mmsDownload != null -> { + ConversationMmsDownloadBody( + download = message.mmsDownload, + canDownloadMessage = message.canDownloadMessage, + isSelected = isSelected, + contentColor = messageBubbleContentColor( + message = message, + isSelected = isSelected, + ), + simDisplayName = simDisplayName, + ) + } + + else -> { + ConversationMessageBody( + content = layout.content, + isIncoming = message.isIncoming, + isSelectionMode = isSelectionMode, + onAttachmentClick = onAttachmentClick, + onExternalUriClick = onExternalUriClick, + onMessageLongClick = onMessageLongClick, + ) + } + } } } diff --git a/src/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessageMetadata.kt b/src/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessageMetadata.kt index 454a979f..61179dbd 100644 --- a/src/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessageMetadata.kt +++ b/src/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessageMetadata.kt @@ -32,6 +32,10 @@ internal fun ConversationMessageMetadata( simDisplayName: String?, onSimSelectorClick: () -> Unit, ) { + if (message.mmsDownload != null) { + return + } + val linkColor = MaterialTheme.colorScheme.primary val resources = LocalResources.current @@ -152,8 +156,6 @@ private fun messageMetadataColor( 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/messages/ui/message/ConversationMessageRows.kt b/src/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessageRows.kt index 8a559c58..8db54d7a 100644 --- a/src/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessageRows.kt +++ b/src/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessageRows.kt @@ -28,11 +28,65 @@ internal fun ConversationMessageBubbleRow( isSelectionMode: Boolean, layout: ConversationMessageLayout, maxBubbleWidth: Dp, + simDisplayName: String?, onAttachmentClick: (contentType: String, contentUri: String) -> Unit, onExternalUriClick: (String) -> Unit, onMessageClick: () -> Unit, + onMessageDownloadClick: () -> Unit, onMessageLongClick: () -> Unit, onMessageResendClick: () -> Unit, +) { + ConversationMessageBubbleRowContainer( + message = message, + isSelected = isSelected, + isSelectionMode = isSelectionMode, + onMessageClick = onMessageClick, + onMessageLongClick = onMessageLongClick, + ) { + ConversationMessageBubble( + modifier = Modifier.conversationMessageBubbleInteractionModifier( + message = message, + isSelectionMode = isSelectionMode, + layout = layout, + onMessageDownloadClick = onMessageDownloadClick, + onMessageLongClick = onMessageLongClick, + onMessageResendClick = onMessageResendClick, + ), + message = message, + isSelected = isSelected, + isSelectionMode = isSelectionMode, + layout = layout, + maxBubbleWidth = maxBubbleWidth, + simDisplayName = simDisplayName, + onAttachmentClick = { contentType, contentUri -> + when { + isSelectionMode -> onMessageClick() + message.canDownloadMessage -> onMessageDownloadClick() + message.canResendMessage -> onMessageResendClick() + else -> onAttachmentClick(contentType, contentUri) + } + }, + onExternalUriClick = { uri -> + when { + isSelectionMode -> onMessageClick() + message.canDownloadMessage -> onMessageDownloadClick() + message.canResendMessage -> onMessageResendClick() + else -> onExternalUriClick(uri) + } + }, + onMessageLongClick = onMessageLongClick, + ) + } +} + +@Composable +private fun ConversationMessageBubbleRowContainer( + message: ConversationMessageUiModel, + isSelected: Boolean, + isSelectionMode: Boolean, + onMessageClick: () -> Unit, + onMessageLongClick: () -> Unit, + content: @Composable () -> Unit, ) { Row( modifier = Modifier @@ -59,35 +113,7 @@ internal fun ConversationMessageBubbleRow( ), verticalAlignment = Alignment.CenterVertically, ) { - ConversationMessageBubble( - modifier = Modifier.conversationMessageBubbleInteractionModifier( - message = message, - isSelectionMode = isSelectionMode, - layout = layout, - onMessageLongClick = onMessageLongClick, - onMessageResendClick = onMessageResendClick, - ), - message = message, - isSelected = isSelected, - isSelectionMode = isSelectionMode, - layout = layout, - maxBubbleWidth = maxBubbleWidth, - onAttachmentClick = { contentType, contentUri -> - when { - isSelectionMode -> onMessageClick() - message.canResendMessage -> onMessageResendClick() - else -> onAttachmentClick(contentType, contentUri) - } - }, - onExternalUriClick = { uri -> - when { - isSelectionMode -> onMessageClick() - message.canResendMessage -> onMessageResendClick() - else -> onExternalUriClick(uri) - } - }, - onMessageLongClick = onMessageLongClick, - ) + content() } } } @@ -141,6 +167,7 @@ private fun Modifier.conversationMessageBubbleInteractionModifier( message: ConversationMessageUiModel, isSelectionMode: Boolean, layout: ConversationMessageLayout, + onMessageDownloadClick: () -> Unit, onMessageLongClick: () -> Unit, onMessageResendClick: () -> Unit, ): Modifier { @@ -155,8 +182,9 @@ private fun Modifier.conversationMessageBubbleInteractionModifier( bubbleModifier.combinedClickable( enabled = true, onClick = { - if (message.canResendMessage) { - onMessageResendClick() + when { + message.canDownloadMessage -> onMessageDownloadClick() + message.canResendMessage -> onMessageResendClick() } }, onLongClick = { diff --git a/src/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessageSimAnnotation.kt b/src/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessageSimAnnotation.kt index b2b16538..6ef54dc5 100644 --- a/src/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessageSimAnnotation.kt +++ b/src/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessageSimAnnotation.kt @@ -16,6 +16,7 @@ internal fun resolveConversationMessageSimDisplayName( return when { simDisplayNameByParticipantId.size <= 1 -> null + message.mmsDownload != null -> displayName !isLastInSimRun -> null else -> displayName } diff --git a/src/com/android/messaging/ui/conversation/messages/ui/message/ConversationMmsDownloadBody.kt b/src/com/android/messaging/ui/conversation/messages/ui/message/ConversationMmsDownloadBody.kt new file mode 100644 index 00000000..478f5e49 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/messages/ui/message/ConversationMmsDownloadBody.kt @@ -0,0 +1,224 @@ +package com.android.messaging.ui.conversation.messages.ui.message + +import android.content.Context +import android.content.res.Resources +import android.text.format.DateUtils +import android.text.format.Formatter +import androidx.annotation.StringRes +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +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.unit.dp +import com.android.messaging.R +import com.android.messaging.ui.conversation.messages.model.message.MmsDownloadUiModel + +private const val MMS_DOWNLOAD_STATUS_SEPARATOR = " • " + +@Composable +internal fun ConversationMmsDownloadBody( + download: MmsDownloadUiModel, + canDownloadMessage: Boolean, + isSelected: Boolean, + contentColor: Color, + simDisplayName: String?, +) { + val supportingColor = mmsDownloadSupportingColor( + isSelected = isSelected, + contentColor = contentColor, + ) + + ConversationMmsDownloadBodyContent( + titleText = stringResource(id = mmsDownloadTitleResId(state = download.state)), + infoText = rememberMmsDownloadInfoText(download = download), + statusLineText = rememberMmsDownloadStatusLineText( + statusText = stringResource(id = mmsDownloadStatusResId(state = download.state)), + simDisplayName = simDisplayName, + ), + contentColor = contentColor, + supportingColor = supportingColor, + statusColor = mmsDownloadStatusColor( + state = download.state, + canDownloadMessage = canDownloadMessage, + isSelected = isSelected, + contentColor = contentColor, + supportingColor = supportingColor, + ), + ) +} + +@Composable +private fun mmsDownloadSupportingColor( + isSelected: Boolean, + contentColor: Color, +): Color { + return when { + isSelected -> contentColor.copy(alpha = 0.7f) + else -> MaterialTheme.colorScheme.onSurfaceVariant + } +} + +@Composable +private fun ConversationMmsDownloadBodyContent( + titleText: String, + infoText: String, + statusLineText: String, + contentColor: Color, + supportingColor: Color, + statusColor: Color, +) { + Column( + verticalArrangement = Arrangement.spacedBy(space = 4.dp), + ) { + Text( + text = titleText, + style = MaterialTheme.typography.bodyLarge, + color = contentColor, + ) + + Text( + text = infoText, + style = MaterialTheme.typography.bodyMedium, + color = supportingColor, + ) + + Text( + text = statusLineText, + style = MaterialTheme.typography.bodyLarge, + color = statusColor, + ) + } +} + +@Composable +private fun rememberMmsDownloadInfoText(download: MmsDownloadUiModel): String { + val context = LocalContext.current + val configuration = LocalConfiguration.current + + return remember( + context, + configuration, + download.sizeBytes, + download.expiryTimestamp, + ) { + buildMmsDownloadInfoText( + context = context, + download = download, + ) + } +} + +@Composable +private fun rememberMmsDownloadStatusLineText( + statusText: String, + simDisplayName: String?, +): String { + val resources = LocalResources.current + val configuration = LocalConfiguration.current + + return remember( + resources, + configuration, + statusText, + simDisplayName, + ) { + buildMmsDownloadStatusLineText( + resources = resources, + statusText = statusText, + simDisplayName = simDisplayName, + ) + } +} + +@Composable +private fun mmsDownloadStatusColor( + state: MmsDownloadUiModel.State, + canDownloadMessage: Boolean, + isSelected: Boolean, + contentColor: Color, + supportingColor: Color, +): Color { + return when { + isSelected -> contentColor + state == MmsDownloadUiModel.State.AwaitingManualDownload && canDownloadMessage -> { + MaterialTheme.colorScheme.primary + } + state == MmsDownloadUiModel.State.DownloadFailed && canDownloadMessage -> { + MaterialTheme.colorScheme.primary + } + state == MmsDownloadUiModel.State.DownloadFailed -> MaterialTheme.colorScheme.error + state == MmsDownloadUiModel.State.ExpiredOrUnavailable -> { + MaterialTheme.colorScheme.error + } + else -> supportingColor + } +} + +@StringRes +private fun mmsDownloadTitleResId(state: MmsDownloadUiModel.State): Int { + return when (state) { + MmsDownloadUiModel.State.AwaitingManualDownload -> { + R.string.message_title_manual_download + } + MmsDownloadUiModel.State.Downloading -> R.string.message_title_downloading + MmsDownloadUiModel.State.DownloadFailed -> R.string.message_title_download_failed + MmsDownloadUiModel.State.ExpiredOrUnavailable -> { + R.string.message_title_download_failed + } + } +} + +@StringRes +private fun mmsDownloadStatusResId(state: MmsDownloadUiModel.State): Int { + return when (state) { + MmsDownloadUiModel.State.AwaitingManualDownload -> { + R.string.message_status_download + } + MmsDownloadUiModel.State.Downloading -> R.string.message_status_downloading + MmsDownloadUiModel.State.DownloadFailed -> R.string.message_status_download + MmsDownloadUiModel.State.ExpiredOrUnavailable -> { + R.string.message_status_download_error + } + } +} + +private fun buildMmsDownloadInfoText( + context: Context, + download: MmsDownloadUiModel, +): String { + val formattedSize = Formatter.formatFileSize(context, download.sizeBytes) + val formattedExpiry = DateUtils.formatDateTime( + context, + download.expiryTimestamp, + DateUtils.FORMAT_SHOW_DATE or + DateUtils.FORMAT_SHOW_TIME or + DateUtils.FORMAT_NUMERIC_DATE or + DateUtils.FORMAT_NO_YEAR, + ) + + return context.getString(R.string.mms_info, formattedSize, formattedExpiry) +} + +private fun buildMmsDownloadStatusLineText( + resources: Resources, + statusText: String, + simDisplayName: String?, +): String { + val simAnnotation = simDisplayName + ?.takeIf { displayName -> displayName.isNotBlank() } + ?.let { displayName -> + resources.getString(R.string.conversation_message_sim_annotation, displayName) + } + + return when { + simAnnotation == null -> statusText + else -> "$statusText$MMS_DOWNLOAD_STATUS_SEPARATOR$simAnnotation" + } +} diff --git a/src/com/android/messaging/ui/conversation/screen/ConversationScreen.kt b/src/com/android/messaging/ui/conversation/screen/ConversationScreen.kt index 3f5928ed..f742e072 100644 --- a/src/com/android/messaging/ui/conversation/screen/ConversationScreen.kt +++ b/src/com/android/messaging/ui/conversation/screen/ConversationScreen.kt @@ -187,6 +187,7 @@ internal fun ConversationScreenScaffold( onAttachmentClick = screenModel::onMessageAttachmentClicked, onExternalUriClick = screenModel::onExternalUriClicked, onMessageClick = screenModel::onMessageClick, + onMessageDownloadClick = screenModel::onMessageDownloadClick, onMessageLongClick = screenModel::onMessageLongClick, onMessageResendClick = screenModel::onMessageResendClick, onSimSelectorClick = simSheetState::show, @@ -325,6 +326,7 @@ private fun ConversationScreenContent( onAttachmentClick: (contentType: String, contentUri: String) -> Unit, onExternalUriClick: (String) -> Unit, onMessageClick: (String) -> Unit, + onMessageDownloadClick: (String) -> Unit, onMessageLongClick: (String) -> Unit, onMessageResendClick: (String) -> Unit, onSimSelectorClick: () -> Unit, @@ -377,6 +379,7 @@ private fun ConversationScreenContent( onAttachmentClick = onAttachmentClick, onExternalUriClick = onExternalUriClick, onMessageClick = onMessageClick, + onMessageDownloadClick = onMessageDownloadClick, onMessageLongClick = onMessageLongClick, onMessageResendClick = onMessageResendClick, onSimSelectorClick = onSimSelectorClick, diff --git a/src/com/android/messaging/ui/conversation/screen/ConversationViewModel.kt b/src/com/android/messaging/ui/conversation/screen/ConversationViewModel.kt index 902b3dd0..f1e15a80 100644 --- a/src/com/android/messaging/ui/conversation/screen/ConversationViewModel.kt +++ b/src/com/android/messaging/ui/conversation/screen/ConversationViewModel.kt @@ -73,6 +73,7 @@ internal interface ConversationScreenModel { ) fun onMessageClick(messageId: String) + fun onMessageDownloadClick(messageId: String) fun onMessageLongClick(messageId: String) fun onMessageResendClick(messageId: String) fun onMessageSelectionActionClick(action: ConversationMessageSelectionAction) @@ -476,6 +477,10 @@ internal class ConversationViewModel @Inject constructor( conversationMessageSelectionDelegate.onMessageClick(messageId = messageId) } + override fun onMessageDownloadClick(messageId: String) { + conversationMessageSelectionDelegate.onMessageDownloadClick(messageId = messageId) + } + override fun onMessageLongClick(messageId: String) { conversationMessageSelectionDelegate.onMessageLongClick(messageId = messageId) } From 60fe0bf13c900d40954e465409f91f58cd0089b5 Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Wed, 13 May 2026 22:29:07 +0300 Subject: [PATCH 116/136] Set minSdk to 36 and add comment regarding U SDK Extensions --- app/build.gradle.kts | 2 +- .../ui/conversation/mediapicker/ConversationMediaPicker.kt | 3 +++ .../mediapicker/ConversationMediaPickerOverlay.kt | 5 ++++- .../ui/conversation/screen/ConversationScreenRoute.kt | 5 +++++ 4 files changed, 13 insertions(+), 2 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index d11b276b..62d49636 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -45,7 +45,7 @@ android { defaultConfig { versionCode = 20000000 + 13 versionName = "13" - minSdk = 35 + minSdk = 36 targetSdk = 35 testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" diff --git a/src/com/android/messaging/ui/conversation/mediapicker/ConversationMediaPicker.kt b/src/com/android/messaging/ui/conversation/mediapicker/ConversationMediaPicker.kt index 77f0e247..10d177fd 100644 --- a/src/com/android/messaging/ui/conversation/mediapicker/ConversationMediaPicker.kt +++ b/src/com/android/messaging/ui/conversation/mediapicker/ConversationMediaPicker.kt @@ -44,6 +44,7 @@ import kotlinx.coroutines.launch private const val TAG = "ConversationMediaPicker" +@RequiresExtension(extension = Build.VERSION_CODES.UPSIDE_DOWN_CAKE, version = 15) @OptIn(ExperimentalMaterial3Api::class, ExperimentalPhotoPickerComposeApi::class) @Composable internal fun ConversationMediaPicker( @@ -134,6 +135,7 @@ private fun rememberConversationEmbeddedPhotoPickerFeatureInfo(): EmbeddedPhotoP } } +@RequiresExtension(extension = Build.VERSION_CODES.UPSIDE_DOWN_CAKE, version = 15) @OptIn(ExperimentalMaterial3Api::class, ExperimentalPhotoPickerComposeApi::class) @Composable private fun rememberConversationEmbeddedPhotoPickerState( @@ -215,6 +217,7 @@ private fun rememberPickerBackedAttachmentRemoveCallback( } } +@RequiresExtension(extension = Build.VERSION_CODES.UPSIDE_DOWN_CAKE, version = 15) @OptIn(ExperimentalMaterial3Api::class, ExperimentalPhotoPickerComposeApi::class) @Composable private fun ConversationMediaPickerContent( diff --git a/src/com/android/messaging/ui/conversation/mediapicker/ConversationMediaPickerOverlay.kt b/src/com/android/messaging/ui/conversation/mediapicker/ConversationMediaPickerOverlay.kt index b6dfd702..6f9ddbf3 100644 --- a/src/com/android/messaging/ui/conversation/mediapicker/ConversationMediaPickerOverlay.kt +++ b/src/com/android/messaging/ui/conversation/mediapicker/ConversationMediaPickerOverlay.kt @@ -1,9 +1,11 @@ package com.android.messaging.ui.conversation.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 @@ -20,6 +22,7 @@ import com.android.messaging.ui.conversation.composer.model.ComposerAttachmentUi 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( @@ -89,7 +92,7 @@ internal fun ConversationMediaPickerOverlay( onAttachmentCaptionChange = onAttachmentCaptionChange, onAttachmentRemove = onAttachmentRemove, photoPickerSourceContentUriByAttachmentContentUri = - photoPickerSourceContentUriByAttachmentContentUri, + photoPickerSourceContentUriByAttachmentContentUri, onPhotoPickerMediaSelected = onPhotoPickerMediaSelected, onPhotoPickerMediaDeselected = onPhotoPickerMediaDeselected, onAttachmentStartRequest = onAttachmentStartRequest, diff --git a/src/com/android/messaging/ui/conversation/screen/ConversationScreenRoute.kt b/src/com/android/messaging/ui/conversation/screen/ConversationScreenRoute.kt index c73c6b4b..0972268f 100644 --- a/src/com/android/messaging/ui/conversation/screen/ConversationScreenRoute.kt +++ b/src/com/android/messaging/ui/conversation/screen/ConversationScreenRoute.kt @@ -1,9 +1,11 @@ package com.android.messaging.ui.conversation.screen 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.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.SnackbarHostState @@ -286,6 +288,8 @@ internal fun ConversationScreenSurface( screenModel = screenModel, ) + // "Call requires version 15 of the U Extensions SDK" is OK in this case: all GrapheneOS + // users will have this version ConversationMediaPickerOverlayHost( modifier = Modifier.fillMaxSize(), uiState = mediaPickerOverlayUiState, @@ -296,6 +300,7 @@ internal fun ConversationScreenSurface( } } +@RequiresExtension(extension = Build.VERSION_CODES.UPSIDE_DOWN_CAKE, version = 15) @Composable private fun ConversationMediaPickerOverlayHost( modifier: Modifier, From 34f99329312faee497485ea8937fa018ea26d967 Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Wed, 13 May 2026 22:40:39 +0300 Subject: [PATCH 117/136] Add a targetSdk 35 clarification comment --- app/build.gradle.kts | 1 + 1 file changed, 1 insertion(+) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 62d49636..efb4eb96 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -46,6 +46,7 @@ android { versionCode = 20000000 + 13 versionName = "13" minSdk = 36 + // Do not upgrade until Compose migration finished to prevent edge-to-edge issues targetSdk = 35 testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" From 94df01e0b9d6c32ca15d8574c2b4ccd94eb4aecd Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Wed, 13 May 2026 22:45:22 +0300 Subject: [PATCH 118/136] Fix ktlint indentation issue --- .../conversation/mediapicker/ConversationMediaPickerOverlay.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/com/android/messaging/ui/conversation/mediapicker/ConversationMediaPickerOverlay.kt b/src/com/android/messaging/ui/conversation/mediapicker/ConversationMediaPickerOverlay.kt index 6f9ddbf3..f9e100c7 100644 --- a/src/com/android/messaging/ui/conversation/mediapicker/ConversationMediaPickerOverlay.kt +++ b/src/com/android/messaging/ui/conversation/mediapicker/ConversationMediaPickerOverlay.kt @@ -92,7 +92,7 @@ internal fun ConversationMediaPickerOverlay( onAttachmentCaptionChange = onAttachmentCaptionChange, onAttachmentRemove = onAttachmentRemove, photoPickerSourceContentUriByAttachmentContentUri = - photoPickerSourceContentUriByAttachmentContentUri, + photoPickerSourceContentUriByAttachmentContentUri, onPhotoPickerMediaSelected = onPhotoPickerMediaSelected, onPhotoPickerMediaDeselected = onPhotoPickerMediaDeselected, onAttachmentStartRequest = onAttachmentStartRequest, From 473ff924988df58736415cf5fc338346cb560211 Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Wed, 13 May 2026 22:54:10 +0300 Subject: [PATCH 119/136] Upgrade dependencies --- gradle/libs.versions.toml | 15 +- gradle/verification-metadata.xml | 1468 +++++++++++++++--------------- 2 files changed, 734 insertions(+), 749 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c86368db..5ebd90b3 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,28 +1,30 @@ [versions] -agp = "9.2.0" +agp = "9.2.1" detekt = "2.0.0-alpha.3" hilt = "2.59.2" kotlin = "2.3.21" kotlinx-serialization = "1.11.0" ksp = "2.3.6" + +#noinspection UnusedVersionCatalogEntry 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" +camerax = "1.6.1" coil = "3.4.0" -compose-bom = "2026.04.01" -coroutines = "1.10.2" +compose-bom = "2026.05.00" +coroutines = "1.11.0" glide = "5.0.7" guava = "33.6.0-android" jsr305 = "3.0.2" kotlinx-collections-immutable = "0.4.0" -libphonenumber = "9.0.29" +libphonenumber = "9.0.30" lifecycle = "2.10.0" navigation3 = "1.1.1" -paging = "3.4.2" +paging = "3.5.0" palette = "1.0.0" photo-picker = "1.0.0-alpha01" preference = "1.2.1" @@ -110,7 +112,6 @@ androidx-test-runner = { module = "androidx.test:runner", version.ref = "android [plugins] android-application = { id = "com.android.application", version.ref = "agp" } -android-library = { id = "com.android.library", version.ref = "agp" } detekt = { id = "dev.detekt", version.ref = "detekt" } hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" } diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml index 3b47154f..ea74ec21 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -254,117 +254,117 @@ - - - + + + - - + + - + - - - + + + - - + + - + - - - + + + - - + + - + - + - - - + + + - - + + - + - + - - - + + + - - + + - + - + - - - + + + - - + + - + - + - - - + + + - - + + - + - - - + + + - - + + - + - - - + + + - - + + - + - + @@ -443,71 +443,71 @@ - - - + + + - - - + + + - - - + + + - + - + - + - - - + + + - - - + + + - + - + - + - - - + + + - - - + + + - + - + - + - - - + + + @@ -515,18 +515,18 @@ - - - + + + - + - + - - + + @@ -580,19 +580,19 @@ - - - + + + - - - + + + - + - + @@ -615,9 +615,9 @@ - - - + + + @@ -630,80 +630,80 @@ - - - + + + - + - + - - + + - - - + + + - - - + + + - + - + - + - - - + + + - - - + + + - + - + - + - - - + + + - - - + + + - + - + - + - - - + + + @@ -711,206 +711,206 @@ - - - + + + - + - + - + - - - + + + - - - + + + - + - + - - - + + + - - - + + + - + - + - + - - - + + + - - - + + + - + - + - + - - - + + + - - - + + + - + - + - - - + + + - - + + - + - - - + + + - - - + + + - + - + - + - - - + + + - - - + + + - + - + - + - - - + + + - - - + + + - + - + - - - + + + - - - + + + - + - + - + - - - + + + - - - + + + - + - + - + - - - + + + - - - + + + - + - + @@ -1093,25 +1093,25 @@ - - + + - - + + - + - - - + + + - - + + - + @@ -1872,66 +1872,66 @@ - - - + + + - - - + + + - + - - + + - - + + - - - + + + - - + + - + - - - + + + - - - + + + - + - + - - + + - - - + + + - - + + - - + + - + @@ -2430,244 +2430,236 @@ - - + + - - + + - + - - + + - - + + - + - - - + + + - - + + - - + + - + - - + + - - + + - + - - - + + + - - + + - + - - + + - - + + - + - - + + - - + + - - + + - - + + - + - - + + - - + + - + - - + + - - + + - + - - + + - - + + - + - - + + - - + + - + - - + + - - + + - + - - + + - - + + - + - - - - - - - - - - + + - - + + - + - - + + - - + + - + - - + + - - + + - + - - + + - - + + - + - - - + + + - - + + - - + + - - - + + + - - + + - - + + - - + + - - + + - + @@ -2679,58 +2671,58 @@ - - - + + + - - + + - - + + - - - + + + - - + + - + - - + + - - + + - + - - + + - - + + - + - - + + - - + + - + @@ -2756,267 +2748,267 @@ - - + + - - + + - + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - + - - + + - - + + - + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - - + + - + - - + + - - + + @@ -3142,6 +3134,9 @@ + + + @@ -3263,12 +3258,12 @@ - - - + + + @@ -3456,14 +3451,6 @@ - - - - - - - - @@ -3553,12 +3540,12 @@ - - - + + + @@ -3668,12 +3655,12 @@ - - - + + + @@ -3797,12 +3784,12 @@ - - - + + + @@ -3825,12 +3812,12 @@ - - - + + + @@ -3864,12 +3851,12 @@ - - - + + + @@ -4014,23 +4001,23 @@ - - - + + + - - + + - - + + - - + + - - - + + + @@ -4277,12 +4264,12 @@ - - - + + + @@ -4311,12 +4298,12 @@ - - - + + + @@ -4352,12 +4339,12 @@ - - - + + + @@ -4762,6 +4749,9 @@ + + + @@ -5289,12 +5279,12 @@ - - - + + + @@ -5317,12 +5307,12 @@ - - - + + + @@ -5416,12 +5406,12 @@ - - - + + + @@ -5430,12 +5420,12 @@ - - - + + + @@ -5463,12 +5453,12 @@ - - - + + + @@ -5599,12 +5589,12 @@ - - - + + + @@ -5613,12 +5603,12 @@ - - - + + + @@ -5638,12 +5628,12 @@ - - - + + + @@ -5652,12 +5642,12 @@ - - - + + + @@ -5680,12 +5670,12 @@ - - - + + + @@ -5732,12 +5722,12 @@ - - - + + + @@ -5846,12 +5836,12 @@ - - - + + + @@ -5860,12 +5850,12 @@ - - - + + + @@ -5900,43 +5890,6 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - @@ -6089,19 +6042,6 @@ - - - - - - - - - - - - - @@ -6204,6 +6144,9 @@ + + + @@ -6228,6 +6171,9 @@ + + + @@ -6274,6 +6220,9 @@ + + + @@ -6452,6 +6401,9 @@ + + + @@ -6468,6 +6420,9 @@ + + + @@ -6657,12 +6612,12 @@ - - - + + + @@ -6715,12 +6670,12 @@ - - - + + + @@ -6834,15 +6789,15 @@ - - - + + + - - + + - - + + @@ -6864,6 +6819,11 @@ + + + + + @@ -6889,9 +6849,9 @@ - - - + + + @@ -6909,15 +6869,15 @@ - - - + + + - - + + - - + + @@ -6977,15 +6937,20 @@ - - - + + + - - + + + + + + + - - + + @@ -7110,12 +7075,12 @@ - - - + + + @@ -7185,11 +7150,6 @@ - - - - - @@ -7457,5 +7417,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + From 0f7665086e387f0ed6d7fcefefd0a4d44cc4092f Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Wed, 13 May 2026 22:54:22 +0300 Subject: [PATCH 120/136] Fix attachments menu position --- .../conversation/composer/ui/ConversationComposeMessageField.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/src/com/android/messaging/ui/conversation/composer/ui/ConversationComposeMessageField.kt b/src/com/android/messaging/ui/conversation/composer/ui/ConversationComposeMessageField.kt index 75f0e27b..01df3236 100644 --- a/src/com/android/messaging/ui/conversation/composer/ui/ConversationComposeMessageField.kt +++ b/src/com/android/messaging/ui/conversation/composer/ui/ConversationComposeMessageField.kt @@ -241,6 +241,7 @@ private fun ConversationComposeAttachmentMenu( ), properties = PopupProperties( focusable = false, + clippingEnabled = false, ), ) { ConversationComposeAttachmentMenuContent( From 7a42ea499e212c4d168f378363b58181a168b9aa Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Thu, 14 May 2026 00:03:21 +0300 Subject: [PATCH 121/136] Add avatars for group conversations --- ...veConversationMessageSimDisplayNameTest.kt | 4 + .../ConversationMessageUiModelMapper.kt | 4 + .../message/ConversationMessageUiModel.kt | 11 + .../messages/ui/ConversationMessages.kt | 32 ++- .../ui/message/ConversationMessage.kt | 68 +++++-- .../ui/message/ConversationMessageAvatar.kt | 190 ++++++++++++++++++ .../ui/message/ConversationMessageRows.kt | 67 +++++- .../conversation/screen/ConversationScreen.kt | 12 +- .../screen/ConversationScreenEffects.kt | 28 ++- .../screen/ConversationViewModel.kt | 31 +++ .../screen/model/ConversationScreenEffect.kt | 8 + 11 files changed, 421 insertions(+), 34 deletions(-) create mode 100644 src/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessageAvatar.kt diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/ui/message/ResolveConversationMessageSimDisplayNameTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/ui/message/ResolveConversationMessageSimDisplayNameTest.kt index 7d40f667..12c68997 100644 --- a/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/ui/message/ResolveConversationMessageSimDisplayNameTest.kt +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/ui/message/ResolveConversationMessageSimDisplayNameTest.kt @@ -1,5 +1,6 @@ package com.android.messaging.ui.conversation.messages.ui.message +import com.android.messaging.datamodel.data.ParticipantData 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.MmsDownloadUiModel @@ -151,7 +152,10 @@ private fun message( isIncoming = isIncoming, senderDisplayName = null, senderAvatarUri = null, + senderContactId = ParticipantData.PARTICIPANT_CONTACT_ID_NOT_RESOLVED, senderContactLookupKey = null, + senderNormalizedDestination = null, + senderParticipantId = null, selfParticipantId = selfParticipantId, canClusterWithPrevious = false, canClusterWithNext = false, diff --git a/src/com/android/messaging/ui/conversation/messages/mapper/ConversationMessageUiModelMapper.kt b/src/com/android/messaging/ui/conversation/messages/mapper/ConversationMessageUiModelMapper.kt index 77c45eec..742ebb65 100644 --- a/src/com/android/messaging/ui/conversation/messages/mapper/ConversationMessageUiModelMapper.kt +++ b/src/com/android/messaging/ui/conversation/messages/mapper/ConversationMessageUiModelMapper.kt @@ -37,7 +37,11 @@ internal class ConversationMessageUiModelMapperImpl @Inject constructor( isIncoming = data.isIncoming, senderDisplayName = data.senderDisplayName, senderAvatarUri = data.senderProfilePhotoUri, + senderContactId = data.senderContactId, senderContactLookupKey = data.senderContactLookupKey, + senderNormalizedDestination = data.senderNormalizedDestination + ?.takeIf { it.isNotBlank() }, + senderParticipantId = data.participantId?.takeIf { it.isNotBlank() }, selfParticipantId = data.selfParticipantId?.takeIf { it.isNotBlank() }, canClusterWithPrevious = data.canClusterWithPreviousMessage, canClusterWithNext = data.canClusterWithNextMessage, diff --git a/src/com/android/messaging/ui/conversation/messages/model/message/ConversationMessageUiModel.kt b/src/com/android/messaging/ui/conversation/messages/model/message/ConversationMessageUiModel.kt index 3feb774c..69487b43 100644 --- a/src/com/android/messaging/ui/conversation/messages/model/message/ConversationMessageUiModel.kt +++ b/src/com/android/messaging/ui/conversation/messages/model/message/ConversationMessageUiModel.kt @@ -3,6 +3,8 @@ package com.android.messaging.ui.conversation.messages.model.message import android.net.Uri import androidx.compose.runtime.Immutable import androidx.compose.runtime.Stable +import com.android.messaging.datamodel.data.ParticipantData + @Immutable internal data class ConversationMessageUiModel( val messageId: String, @@ -16,7 +18,10 @@ internal data class ConversationMessageUiModel( val isIncoming: Boolean, val senderDisplayName: String?, val senderAvatarUri: Uri?, + val senderContactId: Long, val senderContactLookupKey: String?, + val senderNormalizedDestination: String?, + val senderParticipantId: String?, val selfParticipantId: String?, val canClusterWithPrevious: Boolean, val canClusterWithNext: Boolean, @@ -30,6 +35,12 @@ internal data class ConversationMessageUiModel( val protocol: Protocol, ) { + val canShowContactCard: Boolean + get() { + return senderContactId > ParticipantData.PARTICIPANT_CONTACT_ID_NOT_RESOLVED || + !senderNormalizedDestination.isNullOrBlank() + } + @Stable sealed interface Status { data object Unknown : Status 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 5ad8e73e..8af66308 100644 --- a/src/com/android/messaging/ui/conversation/messages/ui/ConversationMessages.kt +++ b/src/com/android/messaging/ui/conversation/messages/ui/ConversationMessages.kt @@ -34,9 +34,11 @@ import com.android.messaging.ui.conversation.messages.ui.message.resolveConversa import com.android.messaging.ui.conversation.resolveDisplayName import java.util.TimeZone import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.ImmutableMap import kotlinx.collections.immutable.ImmutableSet import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentSetOf +import kotlinx.collections.immutable.toImmutableMap private val CONVERSATION_MESSAGES_CONTENT_PADDING = PaddingValues( start = 16.dp, @@ -64,11 +66,12 @@ internal fun ConversationMessages( messages: ImmutableList, listState: LazyListState, selectedMessageIds: ImmutableSet = persistentSetOf(), - showIncomingSenderLabels: Boolean = true, + showIncomingParticipantIdentity: Boolean = true, subscriptions: ImmutableList = persistentListOf(), onAttachmentClick: (contentType: String, contentUri: String) -> Unit, onExternalUriClick: (String) -> Unit, onMessageClick: (String) -> Unit, + onMessageAvatarClick: (String) -> Unit, onMessageDownloadClick: (String) -> Unit, onMessageLongClick: (String) -> Unit, onMessageResendClick: (String) -> Unit, @@ -117,11 +120,12 @@ internal fun ConversationMessages( ), isSelectionMode = selectedMessageIds.isNotEmpty(), isSelected = selectedMessageIds.contains(message.messageId), - showIncomingSenderLabels = showIncomingSenderLabels, + showIncomingParticipantIdentity = showIncomingParticipantIdentity, simDisplayNameByParticipantId = simDisplayNameByParticipantId, onAttachmentClick = onAttachmentClick, onExternalUriClick = onExternalUriClick, onMessageClick = onMessageClick, + onMessageAvatarClick = onMessageAvatarClick, onMessageDownloadClick = onMessageDownloadClick, onMessageLongClick = onMessageLongClick, onMessageResendClick = onMessageResendClick, @@ -134,15 +138,17 @@ internal fun ConversationMessages( @Composable private fun rememberSimDisplayNameByParticipantId( subscriptions: ImmutableList, -): Map { +): ImmutableMap { val resources = LocalResources.current return remember(subscriptions, resources) { - subscriptions.associate { subscription -> - subscription.selfParticipantId to subscription.label.resolveDisplayName( - resources = resources, - ) - } + subscriptions + .associate { subscription -> + subscription.selfParticipantId to subscription.label.resolveDisplayName( + resources = resources, + ) + } + .toImmutableMap() } } @@ -194,11 +200,12 @@ private fun ConversationMessagesItem( messageBelow: ConversationMessageUiModel?, isSelectionMode: Boolean, isSelected: Boolean, - showIncomingSenderLabels: Boolean, - simDisplayNameByParticipantId: Map, + showIncomingParticipantIdentity: Boolean, + simDisplayNameByParticipantId: ImmutableMap, onAttachmentClick: (contentType: String, contentUri: String) -> Unit, onExternalUriClick: (String) -> Unit, onMessageClick: (String) -> Unit, + onMessageAvatarClick: (String) -> Unit, onMessageDownloadClick: (String) -> Unit, onMessageLongClick: (String) -> Unit, onMessageResendClick: (String) -> Unit, @@ -228,13 +235,16 @@ private fun ConversationMessagesItem( isSelected = isSelected, isSelectionMode = isSelectionMode, message = message, - showIncomingSenderLabel = showIncomingSenderLabels, + showIncomingParticipantIdentity = showIncomingParticipantIdentity, simDisplayName = simDisplayName, onAttachmentClick = onAttachmentClick, onExternalUriClick = onExternalUriClick, onMessageClick = { onMessageClick(message.messageId) }, + onMessageAvatarClick = { + onMessageAvatarClick(message.messageId) + }, onMessageDownloadClick = { onMessageDownloadClick(message.messageId) }, 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 d460ce5b..4e85e657 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 @@ -36,11 +36,12 @@ internal fun ConversationMessage( message: ConversationMessageUiModel, isSelected: Boolean = false, isSelectionMode: Boolean = false, - showIncomingSenderLabel: Boolean = true, + showIncomingParticipantIdentity: Boolean = true, simDisplayName: String? = null, onAttachmentClick: (contentType: String, contentUri: String) -> Unit = { _, _ -> }, onExternalUriClick: (String) -> Unit = {}, onMessageClick: () -> Unit = {}, + onMessageAvatarClick: () -> Unit = {}, onMessageDownloadClick: () -> Unit = {}, onMessageLongClick: () -> Unit = {}, onMessageResendClick: () -> Unit = {}, @@ -50,14 +51,25 @@ internal fun ConversationMessage( modifier = modifier .fillMaxWidth(), ) { + val layout = rememberConversationMessageLayout( + message = message, + showIncomingParticipantIdentity = showIncomingParticipantIdentity, + ) + val maxBubbleWidth = remember(maxWidth) { (maxWidth * MESSAGE_BUBBLE_WIDTH_FRACTION) .coerceAtMost(MESSAGE_BUBBLE_MAX_WIDTH_DP.dp) } - val layout = rememberConversationMessageLayout( - message = message, - showIncomingSenderLabel = showIncomingSenderLabel, - ) + + val maxAdjustedBubbleWidth = remember( + maxBubbleWidth, + layout.showAvatarGutter, + ) { + conversationMessageMaxBubbleWidth( + maxBubbleWidth = maxBubbleWidth, + showAvatarGutter = layout.showAvatarGutter, + ) + } Row( modifier = Modifier.fillMaxWidth(), @@ -68,11 +80,12 @@ internal fun ConversationMessage( isSelected = isSelected, isSelectionMode = isSelectionMode, layout = layout, - maxBubbleWidth = maxBubbleWidth, + maxBubbleWidth = maxAdjustedBubbleWidth, simDisplayName = simDisplayName, onAttachmentClick = onAttachmentClick, onExternalUriClick = onExternalUriClick, onMessageClick = onMessageClick, + onMessageAvatarClick = onMessageAvatarClick, onMessageDownloadClick = onMessageDownloadClick, onMessageLongClick = onMessageLongClick, onMessageResendClick = onMessageResendClick, @@ -89,6 +102,8 @@ internal data class ConversationMessageLayout( val content: ConversationMessageContent, val metadataText: String?, val showSender: Boolean, + val showAvatarGutter: Boolean, + val showAvatar: Boolean, ) internal enum class ConversationMessageBubbleLayoutMode { @@ -100,7 +115,7 @@ internal enum class ConversationMessageBubbleLayoutMode { @Composable private fun rememberConversationMessageLayout( message: ConversationMessageUiModel, - showIncomingSenderLabel: Boolean, + showIncomingParticipantIdentity: Boolean, ): ConversationMessageLayout { val bubbleShape = remember( message.canClusterWithPrevious, @@ -112,17 +127,14 @@ private fun rememberConversationMessageLayout( val content = rememberConversationMessageContent(message = message) val metadataText = rememberConversationMessageMetadataText(message = message) - val showSender = remember( - showIncomingSenderLabel, - message.isIncoming, - message.senderDisplayName, - message.canClusterWithPrevious, - ) { - message.isIncoming && - showIncomingSenderLabel && - !message.senderDisplayName.isNullOrBlank() && - !message.canClusterWithPrevious - } + val showSender = message.isIncoming && + showIncomingParticipantIdentity && + !message.senderDisplayName.isNullOrBlank() && + !message.canClusterWithPrevious + + val showAvatarGutter = message.isIncoming && showIncomingParticipantIdentity + + val showAvatar = showAvatarGutter && !message.canClusterWithNext val bubbleLayoutMode = remember( content, @@ -140,6 +152,8 @@ private fun rememberConversationMessageLayout( content, metadataText, showSender, + showAvatarGutter, + showAvatar, ) { ConversationMessageLayout( bubbleShape = bubbleShape, @@ -147,10 +161,26 @@ private fun rememberConversationMessageLayout( content = content, metadataText = metadataText, showSender = showSender, + showAvatarGutter = showAvatarGutter, + showAvatar = showAvatar, ) } } +private fun conversationMessageMaxBubbleWidth( + maxBubbleWidth: Dp, + showAvatarGutter: Boolean, +): Dp { + return when { + showAvatarGutter -> { + (maxBubbleWidth - CONVERSATION_MESSAGE_AVATAR_GUTTER_WIDTH) + .coerceAtLeast(0.dp) + } + + else -> maxBubbleWidth + } +} + @Composable private fun rememberConversationMessageContent( message: ConversationMessageUiModel, @@ -233,6 +263,7 @@ private fun ConversationMessageContent( onAttachmentClick: (contentType: String, contentUri: String) -> Unit, onExternalUriClick: (String) -> Unit, onMessageClick: () -> Unit, + onMessageAvatarClick: () -> Unit, onMessageDownloadClick: () -> Unit, onMessageLongClick: () -> Unit, onMessageResendClick: () -> Unit, @@ -251,6 +282,7 @@ private fun ConversationMessageContent( onAttachmentClick = onAttachmentClick, onExternalUriClick = onExternalUriClick, onMessageClick = onMessageClick, + onMessageAvatarClick = onMessageAvatarClick, onMessageDownloadClick = onMessageDownloadClick, onMessageLongClick = onMessageLongClick, onMessageResendClick = onMessageResendClick, diff --git a/src/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessageAvatar.kt b/src/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessageAvatar.kt new file mode 100644 index 00000000..2debb8c0 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessageAvatar.kt @@ -0,0 +1,190 @@ +package com.android.messaging.ui.conversation.messages.ui.message + +import androidx.compose.foundation.combinedClickable +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 +import androidx.compose.material.icons.rounded.Person +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.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.layout.ContentScale +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import com.android.messaging.sms.MmsSmsUtils +import com.android.messaging.ui.conversation.messages.model.message.ConversationMessageUiModel + +internal val CONVERSATION_MESSAGE_AVATAR_SIZE = 32.dp +internal val CONVERSATION_MESSAGE_AVATAR_GAP = 8.dp +internal val CONVERSATION_MESSAGE_AVATAR_GUTTER_WIDTH = + CONVERSATION_MESSAGE_AVATAR_SIZE + CONVERSATION_MESSAGE_AVATAR_GAP + +@Composable +internal fun ConversationMessageAvatar( + modifier: Modifier = Modifier, + message: ConversationMessageUiModel, + onClick: () -> Unit, + onLongClick: () -> Unit, +) { + val hapticFeedback = LocalHapticFeedback.current + + val label = remember(message.senderDisplayName) { + conversationMessageAvatarLabel(displayName = message.senderDisplayName) + } + + val fallbackColors = rememberConversationMessageAvatarColors(message = message) + + Box( + modifier = modifier + .size(size = CONVERSATION_MESSAGE_AVATAR_SIZE) + .clip(shape = CircleShape) + .combinedClickable( + enabled = true, + onClick = onClick, + onLongClick = { + hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) + onLongClick() + }, + ), + contentAlignment = Alignment.Center, + ) { + ConversationMessageAvatarFallback( + colors = fallbackColors, + label = label, + ) + + message.senderAvatarUri?.let { + AsyncImage( + model = message.senderAvatarUri, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize(), + ) + } + } +} + +@Composable +private fun ConversationMessageAvatarFallback( + modifier: Modifier = Modifier, + colors: ConversationMessageAvatarColors, + label: String?, +) { + Surface( + modifier = modifier.fillMaxSize(), + color = colors.container, + contentColor = colors.content, + shape = CircleShape, + ) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + ConversationMessageAvatarFallbackContent( + label = label, + ) + } + } +} + +@Composable +private fun ConversationMessageAvatarFallbackContent( + modifier: Modifier = Modifier, + label: String?, +) { + when (label) { + null -> { + Icon( + modifier = modifier.size(size = 18.dp), + imageVector = Icons.Rounded.Person, + contentDescription = null, + ) + } + + else -> { + Text( + modifier = modifier, + text = label, + style = MaterialTheme.typography.labelLarge, + ) + } + } +} + +@Composable +private fun rememberConversationMessageAvatarColors( + message: ConversationMessageUiModel, +): ConversationMessageAvatarColors { + val colorScheme = MaterialTheme.colorScheme + + val colorKey = remember(message) { + conversationMessageAvatarColorKey(message = message) + } + + return remember(colorScheme, colorKey) { + val colorOptions = listOf( + ConversationMessageAvatarColors( + container = colorScheme.primaryContainer, + content = colorScheme.onPrimaryContainer, + ), + ConversationMessageAvatarColors( + container = colorScheme.secondaryContainer, + content = colorScheme.onSecondaryContainer, + ), + ConversationMessageAvatarColors( + container = colorScheme.tertiaryContainer, + content = colorScheme.onTertiaryContainer, + ), + ConversationMessageAvatarColors( + container = colorScheme.surfaceContainerHigh, + content = colorScheme.onSurface, + ), + ConversationMessageAvatarColors( + container = colorScheme.surfaceContainerHighest, + content = colorScheme.onSurface, + ), + ) + + val colorIndex = (colorKey.hashCode() and Int.MAX_VALUE) % colorOptions.size + + colorOptions[colorIndex] + } +} + +private fun conversationMessageAvatarColorKey( + message: ConversationMessageUiModel, +): String { + return message.senderParticipantId + ?: message.senderContactLookupKey?.takeIf { it.isNotBlank() } + ?: message.senderDisplayName?.takeIf { it.isNotBlank() } + ?: message.conversationId +} + +private fun conversationMessageAvatarLabel( + displayName: String?, +): String? { + val trimmedDisplayName = displayName?.trim() + + return when { + trimmedDisplayName == null -> null + trimmedDisplayName.isBlank() -> null + MmsSmsUtils.isPhoneNumber(trimmedDisplayName) -> null + else -> trimmedDisplayName.first().uppercaseChar().toString() + } +} + +private data class ConversationMessageAvatarColors( + val container: Color, + val content: Color, +) diff --git a/src/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessageRows.kt b/src/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessageRows.kt index 8db54d7a..1a131381 100644 --- a/src/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessageRows.kt +++ b/src/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessageRows.kt @@ -3,9 +3,11 @@ package com.android.messaging.ui.conversation.messages.ui.message import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.interaction.MutableInteractionSource 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.width import androidx.compose.foundation.layout.widthIn import androidx.compose.runtime.Composable import androidx.compose.runtime.remember @@ -32,6 +34,7 @@ internal fun ConversationMessageBubbleRow( onAttachmentClick: (contentType: String, contentUri: String) -> Unit, onExternalUriClick: (String) -> Unit, onMessageClick: () -> Unit, + onMessageAvatarClick: () -> Unit, onMessageDownloadClick: () -> Unit, onMessageLongClick: () -> Unit, onMessageResendClick: () -> Unit, @@ -40,7 +43,9 @@ internal fun ConversationMessageBubbleRow( message = message, isSelected = isSelected, isSelectionMode = isSelectionMode, + layout = layout, onMessageClick = onMessageClick, + onMessageAvatarClick = onMessageAvatarClick, onMessageLongClick = onMessageLongClick, ) { ConversationMessageBubble( @@ -84,7 +89,9 @@ private fun ConversationMessageBubbleRowContainer( message: ConversationMessageUiModel, isSelected: Boolean, isSelectionMode: Boolean, + layout: ConversationMessageLayout, onMessageClick: () -> Unit, + onMessageAvatarClick: () -> Unit, onMessageLongClick: () -> Unit, content: @Composable () -> Unit, ) { @@ -111,13 +118,55 @@ private fun ConversationMessageBubbleRowContainer( horizontalArrangement = conversationMessageRowHorizontalArrangement( message = message, ), - verticalAlignment = Alignment.CenterVertically, + verticalAlignment = Alignment.Bottom, ) { + ConversationMessageAvatarGutter( + message = message, + isSelectionMode = isSelectionMode, + layout = layout, + onAvatarClick = onMessageAvatarClick, + onMessageClick = onMessageClick, + onMessageLongClick = onMessageLongClick, + ) + content() } } } +@Composable +private fun ConversationMessageAvatarGutter( + message: ConversationMessageUiModel, + isSelectionMode: Boolean, + layout: ConversationMessageLayout, + onAvatarClick: () -> Unit, + onMessageClick: () -> Unit, + onMessageLongClick: () -> Unit, +) { + if (!layout.showAvatarGutter) { + return + } + + Box( + modifier = Modifier + .width(width = CONVERSATION_MESSAGE_AVATAR_GUTTER_WIDTH), + contentAlignment = Alignment.CenterStart, + ) { + if (layout.showAvatar) { + ConversationMessageAvatar( + message = message, + onClick = { + when { + isSelectionMode -> onMessageClick() + else -> onAvatarClick() + } + }, + onLongClick = onMessageLongClick, + ) + } + } +} + private fun conversationMessageRowHorizontalArrangement( message: ConversationMessageUiModel, ): Arrangement.Horizontal { @@ -220,6 +269,8 @@ internal fun ConversationMessageMetadataRow( message = message, ), ) { + ConversationMessageAvatarMetadataOffset(layout = layout) + Column( modifier = Modifier.widthIn(max = maxBubbleWidth), horizontalAlignment = when { @@ -237,3 +288,17 @@ internal fun ConversationMessageMetadataRow( } } } + +@Composable +private fun ConversationMessageAvatarMetadataOffset( + layout: ConversationMessageLayout, +) { + if (!layout.showAvatarGutter) { + return + } + + Box( + modifier = Modifier + .width(width = CONVERSATION_MESSAGE_AVATAR_GUTTER_WIDTH), + ) +} diff --git a/src/com/android/messaging/ui/conversation/screen/ConversationScreen.kt b/src/com/android/messaging/ui/conversation/screen/ConversationScreen.kt index f742e072..da1722bf 100644 --- a/src/com/android/messaging/ui/conversation/screen/ConversationScreen.kt +++ b/src/com/android/messaging/ui/conversation/screen/ConversationScreen.kt @@ -187,6 +187,7 @@ internal fun ConversationScreenScaffold( onAttachmentClick = screenModel::onMessageAttachmentClicked, onExternalUriClick = screenModel::onExternalUriClicked, onMessageClick = screenModel::onMessageClick, + onMessageAvatarClick = screenModel::onMessageAvatarClick, onMessageDownloadClick = screenModel::onMessageDownloadClick, onMessageLongClick = screenModel::onMessageLongClick, onMessageResendClick = screenModel::onMessageResendClick, @@ -326,6 +327,7 @@ private fun ConversationScreenContent( onAttachmentClick: (contentType: String, contentUri: String) -> Unit, onExternalUriClick: (String) -> Unit, onMessageClick: (String) -> Unit, + onMessageAvatarClick: (String) -> Unit, onMessageDownloadClick: (String) -> Unit, onMessageLongClick: (String) -> Unit, onMessageResendClick: (String) -> Unit, @@ -350,7 +352,7 @@ private fun ConversationScreenContent( conversationId = conversationId, ) - val showIncomingSenderLabels = shouldShowIncomingSenderLabels( + val showIncomingParticipantIdentity = shouldShowIncomingParticipantIdentity( metadata = uiState.metadata, ) @@ -374,11 +376,12 @@ private fun ConversationScreenContent( messages = messagesState.messages, listState = messagesListState, selectedMessageIds = uiState.selection.selectedMessageIds, - showIncomingSenderLabels = showIncomingSenderLabels, + showIncomingParticipantIdentity = showIncomingParticipantIdentity, subscriptions = uiState.composer.simSelector.subscriptions, onAttachmentClick = onAttachmentClick, onExternalUriClick = onExternalUriClick, onMessageClick = onMessageClick, + onMessageAvatarClick = onMessageAvatarClick, onMessageDownloadClick = onMessageDownloadClick, onMessageLongClick = onMessageLongClick, onMessageResendClick = onMessageResendClick, @@ -388,9 +391,12 @@ private fun ConversationScreenContent( } } -private fun shouldShowIncomingSenderLabels(metadata: ConversationMetadataUiState): Boolean { +private fun shouldShowIncomingParticipantIdentity( + metadata: ConversationMetadataUiState, +): Boolean { return when (metadata) { is ConversationMetadataUiState.Present -> metadata.participantCount > 1 + ConversationMetadataUiState.Loading, ConversationMetadataUiState.Unavailable, -> false diff --git a/src/com/android/messaging/ui/conversation/screen/ConversationScreenEffects.kt b/src/com/android/messaging/ui/conversation/screen/ConversationScreenEffects.kt index 1e721194..cbbd57de 100644 --- a/src/com/android/messaging/ui/conversation/screen/ConversationScreenEffects.kt +++ b/src/com/android/messaging/ui/conversation/screen/ConversationScreenEffects.kt @@ -4,6 +4,7 @@ import android.content.ActivityNotFoundException import android.content.Context import android.content.Intent import android.graphics.Point +import android.view.View import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.layout.Box @@ -20,6 +21,7 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Rect as ComposeRect import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.LiveRegionMode import androidx.compose.ui.semantics.clearAndSetSemantics @@ -31,6 +33,7 @@ import com.android.messaging.ui.UIIntents import com.android.messaging.ui.conversation.MessageDetailsDialog import com.android.messaging.ui.conversation.screen.model.ConversationScreenEffect import com.android.messaging.util.BuglePrefs +import com.android.messaging.util.ContactUtil import com.android.messaging.util.LogUtil import com.android.messaging.util.MediaUtil import com.android.messaging.util.UiUtils @@ -45,6 +48,7 @@ internal fun ConversationScreenEffects( onNavigateBack: () -> Unit, ) { val context = LocalContext.current + val view = LocalView.current val defaultSmsRoleLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.StartActivityForResult(), ) { result -> @@ -52,10 +56,18 @@ internal fun ConversationScreenEffects( } val draftSentTick = remember { mutableIntStateOf(0) } - LaunchedEffect(screenModel, context, snackbarHostState, hostBoundsState, onNavigateBack) { + LaunchedEffect( + screenModel, + context, + view, + snackbarHostState, + hostBoundsState, + onNavigateBack, + ) { screenModel.effects.collect { effect -> screenModel.handleConversationScreenEffect( context = context, + view = view, snackbarHostState = snackbarHostState, hostBoundsState = hostBoundsState, effect = effect, @@ -71,6 +83,7 @@ internal fun ConversationScreenEffects( private suspend fun ConversationScreenModel.handleConversationScreenEffect( context: Context, + view: View, snackbarHostState: SnackbarHostState, hostBoundsState: State, effect: ConversationScreenEffect, @@ -121,10 +134,12 @@ private suspend fun ConversationScreenModel.handleConversationScreenEffect( is ConversationScreenEffect.PlacePhoneCall, is ConversationScreenEffect.ShowMessage, is ConversationScreenEffect.ShowMessageDetails, + is ConversationScreenEffect.ShowOrAddParticipantContact, is ConversationScreenEffect.ShowSaveAttachmentsResult, -> { handleImmediateConversationScreenEffect( context = context, + view = view, effect = effect, onDraftSent = onDraftSent, ) @@ -134,6 +149,7 @@ private suspend fun ConversationScreenModel.handleConversationScreenEffect( private fun handleImmediateConversationScreenEffect( context: Context, + view: View, effect: ConversationScreenEffect, onDraftSent: () -> Unit, ) { @@ -184,6 +200,16 @@ private fun handleImmediateConversationScreenEffect( ) } + is ConversationScreenEffect.ShowOrAddParticipantContact -> { + ContactUtil.showOrAddContact( + view, + effect.contactId, + effect.contactLookupKey, + effect.avatarUri, + effect.normalizedDestination, + ) + } + is ConversationScreenEffect.ShowSaveAttachmentsResult -> { showSaveAttachmentsResultToast( context = context, diff --git a/src/com/android/messaging/ui/conversation/screen/ConversationViewModel.kt b/src/com/android/messaging/ui/conversation/screen/ConversationViewModel.kt index f1e15a80..e9aa4ae0 100644 --- a/src/com/android/messaging/ui/conversation/screen/ConversationViewModel.kt +++ b/src/com/android/messaging/ui/conversation/screen/ConversationViewModel.kt @@ -73,6 +73,7 @@ internal interface ConversationScreenModel { ) fun onMessageClick(messageId: String) + fun onMessageAvatarClick(messageId: String) fun onMessageDownloadClick(messageId: String) fun onMessageLongClick(messageId: String) fun onMessageResendClick(messageId: String) @@ -477,6 +478,36 @@ internal class ConversationViewModel @Inject constructor( conversationMessageSelectionDelegate.onMessageClick(messageId = messageId) } + override fun onMessageAvatarClick(messageId: String) { + val message = when (val messagesState = conversationMessagesDelegate.state.value) { + is ConversationMessagesUiState.Present -> { + messagesState + .messages + .firstOrNull { candidate -> + candidate.messageId == messageId + } + ?.takeIf { it.canShowContactCard } + } + + else -> null + } + + if (message == null) { + return + } + + viewModelScope.launch(defaultDispatcher) { + _effects.emit( + ConversationScreenEffect.ShowOrAddParticipantContact( + contactId = message.senderContactId, + contactLookupKey = message.senderContactLookupKey, + avatarUri = message.senderAvatarUri, + normalizedDestination = message.senderNormalizedDestination, + ), + ) + } + } + override fun onMessageDownloadClick(messageId: String) { conversationMessageSelectionDelegate.onMessageDownloadClick(messageId = messageId) } diff --git a/src/com/android/messaging/ui/conversation/screen/model/ConversationScreenEffect.kt b/src/com/android/messaging/ui/conversation/screen/model/ConversationScreenEffect.kt index 2720f8a5..4440ddbb 100644 --- a/src/com/android/messaging/ui/conversation/screen/model/ConversationScreenEffect.kt +++ b/src/com/android/messaging/ui/conversation/screen/model/ConversationScreenEffect.kt @@ -1,6 +1,7 @@ package com.android.messaging.ui.conversation.screen.model import android.content.Intent +import android.net.Uri import com.android.messaging.datamodel.data.ConversationMessageData import com.android.messaging.datamodel.data.ConversationParticipantsData import com.android.messaging.datamodel.data.MessageData @@ -58,6 +59,13 @@ internal sealed interface ConversationScreenEffect { val messageResId: Int, ) : ConversationScreenEffect + data class ShowOrAddParticipantContact( + val contactId: Long, + val contactLookupKey: String?, + val avatarUri: Uri?, + val normalizedDestination: String?, + ) : ConversationScreenEffect + data class ShowMessageDetails( val message: ConversationMessageData, val participants: ConversationParticipantsData, From d01c2e48b7e21667850d688f2083e0f7e199c96b Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Thu, 14 May 2026 00:08:52 +0300 Subject: [PATCH 122/136] Replace fakes with mocks --- .../RecipientPickerDelegateImplTest.kt | 60 +++++++++++-------- 1 file changed, 34 insertions(+), 26 deletions(-) diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/recipientpicker/delegate/RecipientPickerDelegateImplTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/recipientpicker/delegate/RecipientPickerDelegateImplTest.kt index 92102142..5eed8921 100644 --- a/app/src/test/kotlin/com/android/messaging/ui/conversation/recipientpicker/delegate/RecipientPickerDelegateImplTest.kt +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/recipientpicker/delegate/RecipientPickerDelegateImplTest.kt @@ -18,7 +18,6 @@ import io.mockk.unmockkAll import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.UnconfinedTestDispatcher @@ -323,8 +322,10 @@ internal class RecipientPickerDelegateImplTest { ): RecipientPickerDelegateImpl { return RecipientPickerDelegateImpl( contactDestinationFormatter = ContactDestinationFormatterImpl(), - contactsRepository = FakeRepository(pages = pages), - isReadContactsPermissionGranted = FakePermission(granted = isPermissionGranted), + contactsRepository = mockContactsRepository(pages = pages), + isReadContactsPermissionGranted = mockIsReadContactsPermissionGranted( + isPermissionGranted = isPermissionGranted, + ), savedStateHandle = SavedStateHandle( initialState = mapOf("search_query" to initialQuery), ), @@ -332,6 +333,36 @@ internal class RecipientPickerDelegateImplTest { ) } + private fun mockContactsRepository( + pages: Map, + ): ContactsRepository { + val contactsRepository = mockk() + every { + contactsRepository.searchContacts( + query = any(), + offset = any(), + ) + } answers { + val key = searchKey( + query = firstArg(), + offset = secondArg(), + ) + val page = pages[key] ?: emptyPage() + flowOf(page) + } + + return contactsRepository + } + + private fun mockIsReadContactsPermissionGranted( + isPermissionGranted: Boolean, + ): IsReadContactsPermissionGranted { + val isReadContactsPermissionGranted = mockk() + every { isReadContactsPermissionGranted() } returns isPermissionGranted + + return isReadContactsPermissionGranted + } + private fun searchKey(query: String, offset: Int): SearchKey { return SearchKey(query = query, offset = offset) } @@ -392,27 +423,4 @@ internal class RecipientPickerDelegateImplTest { val query: String, val offset: Int, ) - - private class FakeRepository( - private val pages: Map, - ) : ContactsRepository { - - override fun searchContacts( - query: String, - offset: Int, - ): Flow { - val key = SearchKey(query = query, offset = offset) - val page = pages[key] ?: ContactsPage( - contacts = persistentListOf(), - nextOffset = null, - ) - return flowOf(page) - } - } - - private class FakePermission( - private val granted: Boolean, - ) : IsReadContactsPermissionGranted { - override fun invoke(): Boolean = granted - } } From ec5058d41c0cc1e32e77081779891d73b69fc841 Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Thu, 14 May 2026 00:11:22 +0300 Subject: [PATCH 123/136] Fix deps pinning --- gradle/verification-metadata.xml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml index ea74ec21..d162dcd0 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -7441,5 +7441,10 @@ + + + + + From a174c5e1a3090ff07bb5cfb92edef26a2448826f Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Thu, 14 May 2026 00:22:51 +0300 Subject: [PATCH 124/136] Fix incorrect send button long press handling --- .../ui/ConversationSendActionButton.kt | 114 +++++++++++++----- .../ui/ConversationSendActionButtonGesture.kt | 29 +---- 2 files changed, 85 insertions(+), 58 deletions(-) diff --git a/src/com/android/messaging/ui/conversation/composer/ui/ConversationSendActionButton.kt b/src/com/android/messaging/ui/conversation/composer/ui/ConversationSendActionButton.kt index c6ae62b2..05421218 100644 --- a/src/com/android/messaging/ui/conversation/composer/ui/ConversationSendActionButton.kt +++ b/src/com/android/messaging/ui/conversation/composer/ui/ConversationSendActionButton.kt @@ -18,6 +18,7 @@ import androidx.compose.animation.scaleIn import androidx.compose.animation.scaleOut import androidx.compose.animation.togetherWith import androidx.compose.foundation.background +import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.size @@ -25,16 +26,16 @@ 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 import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface 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.draw.alpha import androidx.compose.ui.draw.scale @@ -42,6 +43,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.onClick import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp @@ -135,7 +137,6 @@ internal fun ConversationSendActionButton( onRecordGestureLock = onRecordGestureLock, onRecordGestureFinish = onRecordGestureFinish, onLockedStopClick = onLockedStopClick, - onSendActionLongClick = onSendActionLongClick, ) ConversationSendActionButtonLayout( @@ -146,6 +147,7 @@ internal fun ConversationSendActionButton( mode = mode, onClick = onClick, onLockedStopClick = onLockedStopClick, + onSendActionLongClick = onSendActionLongClick, visualState = visualState, ) } @@ -215,6 +217,7 @@ private fun ConversationSendActionButtonLayout( mode: ConversationSendActionButtonMode, onClick: () -> Unit, onLockedStopClick: () -> Unit, + onSendActionLongClick: () -> Unit, visualState: ConversationSendActionButtonVisualState, ) { Box( @@ -230,6 +233,7 @@ private fun ConversationSendActionButtonLayout( mode = mode, onClick = onClick, onLockedStopClick = onLockedStopClick, + onSendActionLongClick = onSendActionLongClick, visualState = visualState, ) } @@ -242,14 +246,62 @@ private fun ConversationSendActionButtonContent( mode: ConversationSendActionButtonMode, onClick: () -> Unit, onLockedStopClick: () -> Unit, + onSendActionLongClick: () -> Unit, visualState: ConversationSendActionButtonVisualState, ) { + val containerColor = when { + enabled -> visualState.containerColor + else -> MaterialTheme.colorScheme.surfaceContainerHighest + } + + val contentColor = when { + enabled -> visualState.contentColor + else -> MaterialTheme.colorScheme.onSurfaceVariant + } + + Surface( + modifier = Modifier + .fillMaxSize() + .scale(scale = visualState.buttonScale) + .stopSemanticsModifier( + mode = mode, + onLockedStopClick = onLockedStopClick, + ) + .then(modifier), + shape = CircleShape, + color = containerColor, + contentColor = contentColor, + ) { + Box( + modifier = Modifier + .fillMaxSize() + .sendClickModifier( + mode = mode, + enabled = enabled, + onClick = onClick, + onSendActionLongClick = onSendActionLongClick, + ), + contentAlignment = Alignment.Center, + ) { + ConversationSendActionButtonIcon( + mode = mode, + ) + } + } +} + +@Composable +private fun Modifier.stopSemanticsModifier( + mode: ConversationSendActionButtonMode, + onLockedStopClick: () -> Unit, +): Modifier { val stopContentDescription = stringResource( id = R.string.audio_record_stop_content_description, ) - val stopSemanticsModifier = when (mode) { + + return when (mode) { ConversationSendActionButtonMode.Stop -> { - Modifier.semantics { + semantics { onClick(label = stopContentDescription) { onLockedStopClick() true @@ -257,37 +309,39 @@ private fun ConversationSendActionButtonContent( } } - else -> Modifier + else -> this } +} - FilledIconButton( - modifier = Modifier - .fillMaxSize() - .scale(scale = visualState.buttonScale) - .then(stopSemanticsModifier) - .then(modifier), - onClick = { - if (mode == ConversationSendActionButtonMode.Send) { - onClick() - } - }, - enabled = enabled, - shape = CircleShape, - colors = IconButtonDefaults.filledIconButtonColors( - containerColor = visualState.containerColor, - contentColor = visualState.contentColor, - disabledContainerColor = MaterialTheme.colorScheme.surfaceContainerHighest, - disabledContentColor = MaterialTheme.colorScheme.onSurfaceVariant, - ), - ) { - ConversationSendActionButtonIcon( - mode = mode, - ) +@Composable +private fun Modifier.sendClickModifier( + mode: ConversationSendActionButtonMode, + enabled: Boolean, + onClick: () -> Unit, + onSendActionLongClick: () -> Unit, +): Modifier { + return when (mode) { + ConversationSendActionButtonMode.Send -> { + combinedClickable( + enabled = enabled, + role = Role.Button, + onClickLabel = stringResource(R.string.sendButtonContentDescription), + onLongClickLabel = stringResource( + id = R.string.sim_selector_button_content_description, + ), + onClick = onClick, + onLongClick = onSendActionLongClick, + ) + } + + else -> this } } @Composable -private fun ConversationSendActionButtonIcon(mode: ConversationSendActionButtonMode) { +private fun ConversationSendActionButtonIcon( + mode: ConversationSendActionButtonMode, +) { AnimatedContent( targetState = mode, transitionSpec = { diff --git a/src/com/android/messaging/ui/conversation/composer/ui/ConversationSendActionButtonGesture.kt b/src/com/android/messaging/ui/conversation/composer/ui/ConversationSendActionButtonGesture.kt index fba754cf..aa5ea6fb 100644 --- a/src/com/android/messaging/ui/conversation/composer/ui/ConversationSendActionButtonGesture.kt +++ b/src/com/android/messaging/ui/conversation/composer/ui/ConversationSendActionButtonGesture.kt @@ -7,13 +7,10 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Modifier -import androidx.compose.ui.hapticfeedback.HapticFeedback -import androidx.compose.ui.hapticfeedback.HapticFeedbackType 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.LocalHapticFeedback import com.android.messaging.ui.conversation.composer.model.ConversationSendActionButtonGestureState import com.android.messaging.ui.conversation.composer.model.ConversationSendActionButtonMode @@ -31,7 +28,6 @@ internal fun Modifier.conversationSendActionButtonGesture( onRecordGestureLock: () -> Boolean, onRecordGestureFinish: (Boolean) -> Unit, onLockedStopClick: () -> Unit, - onSendActionLongClick: () -> Unit, ): Modifier { val currentIsRecordingActive by rememberUpdatedState(newValue = isRecordingActive) val currentIsRecordingLocked by rememberUpdatedState(newValue = isRecordingLocked) @@ -41,10 +37,8 @@ internal fun Modifier.conversationSendActionButtonGesture( val currentOnRecordGestureLock by rememberUpdatedState(newValue = onRecordGestureLock) val currentOnRecordGestureFinish by rememberUpdatedState(newValue = onRecordGestureFinish) val currentOnLockedStopClick by rememberUpdatedState(newValue = onLockedStopClick) - val currentOnSendActionLongClick by rememberUpdatedState(newValue = onSendActionLongClick) - val hapticFeedback = LocalHapticFeedback.current - if (mode != ConversationSendActionButtonMode.Send && !enabled) { + if (mode == ConversationSendActionButtonMode.Send || !enabled) { return this } @@ -57,13 +51,6 @@ internal fun Modifier.conversationSendActionButtonGesture( val isLockedRecording = currentIsRecordingActive && currentIsRecordingLocked when { - mode == ConversationSendActionButtonMode.Send -> { - handleSendModeLongPress( - hapticFeedback = hapticFeedback, - onSendActionLongClick = currentOnSendActionLongClick, - ) - } - isLockedRecording -> { handleLockedRecordGesture( cancelThresholdPx = cancelThresholdPx, @@ -90,20 +77,6 @@ internal fun Modifier.conversationSendActionButtonGesture( } } -private suspend fun AwaitPointerEventScope.handleSendModeLongPress( - hapticFeedback: HapticFeedback, - onSendActionLongClick: () -> Unit, -) { - val initialDown = awaitFirstDown(requireUnconsumed = false) - - val longPressChange = awaitLongPressOrCancellation(pointerId = initialDown.id) - ?: return - - longPressChange.consume() - hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) - onSendActionLongClick() -} - private suspend fun AwaitPointerEventScope.handleRecordGesture( cancelThresholdPx: Float, lockThresholdPx: Float, From 35f87c3ebba7ae67403c88de98c6df12ea53fcce Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Thu, 14 May 2026 17:43:24 +0300 Subject: [PATCH 125/136] Fix message bubble resize on selection when font size is small --- .../ConversationMessageLinkLongClickTest.kt | 70 +++++++++++++++++++ .../ConversationMessageSelectionIndicator.kt | 9 ++- 2 files changed, 76 insertions(+), 3 deletions(-) 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 index ce0d64e2..f238edb7 100644 --- 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 @@ -1,13 +1,21 @@ package com.android.messaging.ui.conversation.messages.ui.message +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.getUnclippedBoundsInRoot import androidx.compose.ui.test.junit4.v2.createComposeRule import androidx.compose.ui.test.longClick +import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performTouchInput +import androidx.compose.ui.unit.Density +import com.android.messaging.datamodel.data.ParticipantData 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 @@ -17,7 +25,10 @@ import org.junit.Test private const val MESSAGE_ID = "message-id" private const val CONVERSATION_ID = "conversation-id" +private const val HEIGHT_ASSERTION_DELTA_DP = 0.5f private const val LINK_ONLY_TEXT = "https://example.com" +private const val MESSAGE_TEST_TAG = "conversation-message" +private const val MINIMAL_FONT_SCALE = 0.85f private const val PLAIN_TEXT = "plain outgoing message" private const val TIMESTAMP = 1_700_000_000_000L @@ -140,6 +151,62 @@ internal class ConversationMessageLinkLongClickTest { assertEquals(1, messageLongClickCount) } } + + @Test + fun selectionModeDoesNotChangePlainTextMessageHeightAtSmallFontScale() { + var isSelected by mutableStateOf(false) + var isSelectionMode by mutableStateOf(false) + + composeRule.setContent { + val density = LocalDensity.current + + CompositionLocalProvider( + LocalDensity provides Density( + density = density.density, + fontScale = MINIMAL_FONT_SCALE, + ), + ) { + AppTheme { + ConversationMessage( + modifier = Modifier.testTag(tag = MESSAGE_TEST_TAG), + message = outgoingMessage(text = PLAIN_TEXT), + isSelected = isSelected, + isSelectionMode = isSelectionMode, + ) + } + } + } + + composeRule.waitForIdle() + + val unselectedHeight = composeRule + .onNodeWithTag(testTag = MESSAGE_TEST_TAG) + .getUnclippedBoundsInRoot() + .let { bounds -> + bounds.bottom - bounds.top + } + + composeRule.runOnIdle { + isSelected = true + isSelectionMode = true + } + composeRule.waitForIdle() + + val selectedHeight = composeRule + .onNodeWithTag(testTag = MESSAGE_TEST_TAG) + .getUnclippedBoundsInRoot() + .let { bounds -> + bounds.bottom - bounds.top + } + + composeRule.runOnIdle { + assertEquals( + unselectedHeight.value, + selectedHeight.value, + HEIGHT_ASSERTION_DELTA_DP, + ) + } + } } private fun outgoingMessage(text: String): ConversationMessageUiModel { @@ -159,7 +226,10 @@ private fun outgoingMessage(text: String): ConversationMessageUiModel { isIncoming = false, senderDisplayName = null, senderAvatarUri = null, + senderContactId = ParticipantData.PARTICIPANT_CONTACT_ID_NOT_RESOLVED, senderContactLookupKey = null, + senderNormalizedDestination = null, + senderParticipantId = null, selfParticipantId = null, canClusterWithPrevious = false, canClusterWithNext = false, diff --git a/src/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessageSelectionIndicator.kt b/src/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessageSelectionIndicator.kt index 30358e84..af4c4e4a 100644 --- a/src/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessageSelectionIndicator.kt +++ b/src/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessageSelectionIndicator.kt @@ -19,6 +19,7 @@ import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape @@ -35,7 +36,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.unit.dp -private val MESSAGE_SELECTION_INDICATOR_TOUCH_SIZE = 48.dp +private val MESSAGE_SELECTION_INDICATOR_GUTTER_WIDTH = 48.dp private val MESSAGE_SELECTION_INDICATOR_SIZE = 22.dp private val MESSAGE_SELECTION_INDICATOR_CHECK_SIZE = 16.dp private val MESSAGE_SELECTION_INDICATOR_BORDER_WIDTH = 2.dp @@ -108,7 +109,9 @@ private fun ConversationMessageSelectionIndicatorContent( val checkmarkScale by selectionTransition.animateSelectionIndicatorCheckmarkScale() Box( - modifier = Modifier.size(size = MESSAGE_SELECTION_INDICATOR_TOUCH_SIZE), + modifier = Modifier + .width(width = MESSAGE_SELECTION_INDICATOR_GUTTER_WIDTH) + .height(height = MESSAGE_SELECTION_INDICATOR_SIZE), contentAlignment = Alignment.Center, ) { Box( @@ -167,7 +170,7 @@ internal fun ConversationMessageSelectionIndicatorOffset( ) { Spacer( modifier = Modifier - .width(width = MESSAGE_SELECTION_INDICATOR_TOUCH_SIZE), + .width(width = MESSAGE_SELECTION_INDICATOR_GUTTER_WIDTH), ) } } From dff7a2c21b55c9f479f36ad3bb0dfb183863beee Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Thu, 14 May 2026 18:08:27 +0300 Subject: [PATCH 126/136] Properly disable message input field when recording audio --- .../composer/ui/ConversationComposeBar.kt | 20 ++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/src/com/android/messaging/ui/conversation/composer/ui/ConversationComposeBar.kt b/src/com/android/messaging/ui/conversation/composer/ui/ConversationComposeBar.kt index 6fd1feec..e89c3f1a 100644 --- a/src/com/android/messaging/ui/conversation/composer/ui/ConversationComposeBar.kt +++ b/src/com/android/messaging/ui/conversation/composer/ui/ConversationComposeBar.kt @@ -33,7 +33,9 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource @@ -226,6 +228,15 @@ internal fun ConversationComposeInputContent( isRecordActionEnabled = isRecordActionEnabled, isSendActionEnabled = isSendActionEnabled, ) + val focusManager = LocalFocusManager.current + val keyboardController = LocalSoftwareKeyboardController.current + + LaunchedEffect(inputState.isActiveRecording) { + if (inputState.isActiveRecording) { + focusManager.clearFocus(force = true) + keyboardController?.hide() + } + } Row( modifier = Modifier @@ -386,6 +397,9 @@ private fun ConversationComposeMessageRecordingContent( onSubjectChipClick: () -> Unit, onSubjectChipClear: () -> Unit, ) { + val isTextEntryEnabled = isMessageFieldEnabled && !inputState.isActiveRecording + val isInputActionEnabled = !inputState.isActiveRecording + Surface( modifier = modifier, shape = presentation.fieldShape, @@ -409,13 +423,13 @@ private fun ConversationComposeMessageRecordingContent( onMessageTextChange(updatedMessageText) } }, - enabled = isMessageFieldEnabled, + enabled = isTextEntryEnabled, sendProtocol = sendProtocol, isVisuallyHidden = inputState.isActiveRecording, messageFieldFocusRequester = messageFieldFocusRequester, presentation = presentation, - isAttachmentActionEnabled = isAttachmentActionEnabled, - isAudioRecordActionEnabled = isRecordActionEnabled, + isAttachmentActionEnabled = isAttachmentActionEnabled && isInputActionEnabled, + isAudioRecordActionEnabled = isRecordActionEnabled && isInputActionEnabled, onContactAttachClick = onContactAttachClick, onMediaPickerClick = onMediaPickerClick, onAudioAttachClick = onLockedAudioRecordingStartRequest, From 60b1ab972b5a1a624bd616079b692fbb50ef9019 Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Thu, 14 May 2026 19:02:08 +0300 Subject: [PATCH 127/136] Display avatar for attached contacts --- .../mapper/ConversationVCardMetadataMapper.kt | 4 + .../ConversationVCardAttachmentMetadata.kt | 1 + ...onversationVCardAttachmentUiModelMapper.kt | 7 + .../ConversationVCardAttachmentUiModel.kt | 1 + .../ConversationVCardAttachmentCardContent.kt | 150 ++++++++++++++++-- .../ui/ConversationAttachmentPreview.kt | 1 + .../ConversationInlineAttachment.kt | 1 + .../ConversationAttachmentSectionsBuilder.kt | 42 +++-- .../ConversationVCardInlineAttachmentRow.kt | 1 + .../ui/message/ConversationMessage.kt | 1 + .../ConversationMessageContentBuilder.kt | 13 +- 11 files changed, 198 insertions(+), 24 deletions(-) diff --git a/src/com/android/messaging/data/conversation/mapper/ConversationVCardMetadataMapper.kt b/src/com/android/messaging/data/conversation/mapper/ConversationVCardMetadataMapper.kt index c047bc87..f8a887c6 100644 --- a/src/com/android/messaging/data/conversation/mapper/ConversationVCardMetadataMapper.kt +++ b/src/com/android/messaging/data/conversation/mapper/ConversationVCardMetadataMapper.kt @@ -33,6 +33,10 @@ internal class ConversationVCardMetadataMapperImpl @Inject constructor() : isLocation -> ConversationVCardAttachmentType.LOCATION else -> ConversationVCardAttachmentType.CONTACT }, + avatarUri = vCardContactItemData + .avatarUri + ?.toString() + ?.takeIf { avatarUri -> avatarUri.isNotBlank() }, displayName = vCardContactItemData .displayName ?.takeIf { title -> title.isNotBlank() }, diff --git a/src/com/android/messaging/data/conversation/model/attachment/ConversationVCardAttachmentMetadata.kt b/src/com/android/messaging/data/conversation/model/attachment/ConversationVCardAttachmentMetadata.kt index 7d36c052..20bf46ed 100644 --- a/src/com/android/messaging/data/conversation/model/attachment/ConversationVCardAttachmentMetadata.kt +++ b/src/com/android/messaging/data/conversation/model/attachment/ConversationVCardAttachmentMetadata.kt @@ -17,6 +17,7 @@ internal sealed interface ConversationVCardAttachmentMetadata { @Immutable data class Loaded( val type: ConversationVCardAttachmentType, + val avatarUri: String?, val displayName: String?, val details: String?, val locationAddress: String?, diff --git a/src/com/android/messaging/ui/conversation/attachment/mapper/ConversationVCardAttachmentUiModelMapper.kt b/src/com/android/messaging/ui/conversation/attachment/mapper/ConversationVCardAttachmentUiModelMapper.kt index 29988a92..ce841d68 100644 --- a/src/com/android/messaging/ui/conversation/attachment/mapper/ConversationVCardAttachmentUiModelMapper.kt +++ b/src/com/android/messaging/ui/conversation/attachment/mapper/ConversationVCardAttachmentUiModelMapper.kt @@ -39,6 +39,7 @@ internal class ConversationVCardAttachmentUiModelMapperImpl @Inject constructor( return when (metadata) { ConversationVCardAttachmentMetadata.Failed -> { createConversationContactUiModel( + avatarUri = null, titleText = null, titleTextResId = defaultTitleTextResId, subtitleText = null, @@ -48,6 +49,7 @@ internal class ConversationVCardAttachmentUiModelMapperImpl @Inject constructor( ConversationVCardAttachmentMetadata.Loading -> { createConversationContactUiModel( + avatarUri = null, titleText = null, titleTextResId = defaultTitleTextResId, subtitleText = null, @@ -59,6 +61,7 @@ internal class ConversationVCardAttachmentUiModelMapperImpl @Inject constructor( null, -> { createConversationContactUiModel( + avatarUri = null, titleText = null, titleTextResId = defaultTitleTextResId, subtitleText = null, @@ -86,6 +89,7 @@ internal class ConversationVCardAttachmentUiModelMapperImpl @Inject constructor( return when (metadata.type) { ConversationVCardAttachmentType.CONTACT -> { createConversationContactUiModel( + avatarUri = metadata.avatarUri, titleText = metadata.displayName, titleTextResId = if (metadata.displayName == null) { defaultTitleTextResId @@ -104,6 +108,7 @@ internal class ConversationVCardAttachmentUiModelMapperImpl @Inject constructor( ConversationVCardAttachmentType.LOCATION -> { ConversationVCardAttachmentUiModel( type = ConversationVCardAttachmentType.LOCATION, + avatarUri = metadata.avatarUri, titleText = metadata.displayName, titleTextResId = if (metadata.displayName == null) { locationTitleTextResId @@ -118,6 +123,7 @@ internal class ConversationVCardAttachmentUiModelMapperImpl @Inject constructor( } private fun createConversationContactUiModel( + avatarUri: String?, titleText: String?, titleTextResId: Int?, subtitleText: String?, @@ -125,6 +131,7 @@ internal class ConversationVCardAttachmentUiModelMapperImpl @Inject constructor( ): ConversationVCardAttachmentUiModel { return ConversationVCardAttachmentUiModel( type = ConversationVCardAttachmentType.CONTACT, + avatarUri = avatarUri, titleText = titleText, titleTextResId = titleTextResId, subtitleText = subtitleText, diff --git a/src/com/android/messaging/ui/conversation/attachment/model/ConversationVCardAttachmentUiModel.kt b/src/com/android/messaging/ui/conversation/attachment/model/ConversationVCardAttachmentUiModel.kt index 07dd0253..12893ebe 100644 --- a/src/com/android/messaging/ui/conversation/attachment/model/ConversationVCardAttachmentUiModel.kt +++ b/src/com/android/messaging/ui/conversation/attachment/model/ConversationVCardAttachmentUiModel.kt @@ -6,6 +6,7 @@ import com.android.messaging.data.conversation.model.attachment.ConversationVCar @Immutable internal data class ConversationVCardAttachmentUiModel( val type: ConversationVCardAttachmentType, + val avatarUri: String? = null, val titleText: String? = null, val titleTextResId: Int? = null, val subtitleText: String? = null, diff --git a/src/com/android/messaging/ui/conversation/attachment/ui/ConversationVCardAttachmentCardContent.kt b/src/com/android/messaging/ui/conversation/attachment/ui/ConversationVCardAttachmentCardContent.kt index 4b5dff86..20c48472 100644 --- a/src/com/android/messaging/ui/conversation/attachment/ui/ConversationVCardAttachmentCardContent.kt +++ b/src/com/android/messaging/ui/conversation/attachment/ui/ConversationVCardAttachmentCardContent.kt @@ -4,24 +4,38 @@ 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.size +import androidx.compose.foundation.shape.CircleShape 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.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.res.stringResource +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.core.net.toUri +import coil3.compose.AsyncImage import com.android.messaging.data.conversation.model.attachment.ConversationVCardAttachmentType +import com.android.messaging.util.UriUtil + +private val VCARD_AVATAR_SIZE = 36.dp +private val VCARD_AVATAR_ICON_SIZE = 20.dp @Composable internal fun ConversationVCardAttachmentCardContent( modifier: Modifier = Modifier, type: ConversationVCardAttachmentType, + avatarUri: String?, titleText: String?, titleTextResId: Int?, subtitleText: String?, @@ -42,26 +56,22 @@ internal fun ConversationVCardAttachmentCardContent( 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, - ) - } + ConversationVCardAttachmentLeadingVisual( + type = type, + avatarUri = avatarUri, + titleText = titleText, + ) Column( + modifier = Modifier.weight(weight = 1f, fill = false), verticalArrangement = Arrangement.spacedBy(space = 2.dp), ) { Text( text = title, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, ) subtitle?.let { subtitleText -> @@ -69,12 +79,128 @@ internal fun ConversationVCardAttachmentCardContent( text = subtitleText, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } +} + +@Composable +private fun ConversationVCardAttachmentLeadingVisual( + type: ConversationVCardAttachmentType, + avatarUri: String?, + titleText: String?, +) { + when (type) { + ConversationVCardAttachmentType.CONTACT -> { + ConversationVCardAttachmentAvatar( + avatarUri = avatarUri, + titleText = titleText, + ) + } + + ConversationVCardAttachmentType.LOCATION -> { + Box( + modifier = Modifier + .size(size = VCARD_AVATAR_SIZE), + contentAlignment = Alignment.Center, + ) { + Icon( + modifier = Modifier.size(size = VCARD_AVATAR_ICON_SIZE), + imageVector = Icons.Rounded.Place, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, ) } } } } +@Composable +private fun ConversationVCardAttachmentAvatar( + avatarUri: String?, + titleText: String?, +) { + val displayableAvatarUri = remember(avatarUri) { + displayableVCardAvatarUri(avatarUri = avatarUri) + } + + val label = remember(titleText) { + vCardAvatarLabel(titleText = titleText) + } + + Box( + modifier = Modifier + .size(size = VCARD_AVATAR_SIZE) + .clip(shape = CircleShape), + contentAlignment = Alignment.Center, + ) { + ConversationVCardAttachmentAvatarFallback(label = label) + + displayableAvatarUri?.let { uri -> + AsyncImage( + modifier = Modifier.fillMaxSize(), + model = uri, + contentDescription = null, + contentScale = ContentScale.Crop, + ) + } + } +} + +@Composable +private fun ConversationVCardAttachmentAvatarFallback( + label: String?, +) { + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.secondaryContainer, + contentColor = MaterialTheme.colorScheme.onSecondaryContainer, + shape = CircleShape, + ) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + when (label) { + null -> { + Icon( + modifier = Modifier.size(size = VCARD_AVATAR_ICON_SIZE), + imageVector = Icons.Rounded.Person, + contentDescription = null, + ) + } + + else -> { + Text( + text = label, + style = MaterialTheme.typography.labelLarge, + ) + } + } + } + } +} + +private fun displayableVCardAvatarUri(avatarUri: String?): String? { + return avatarUri + ?.takeIf { it.isNotBlank() } + ?.toUri() + ?.takeIf(UriUtil::isLocalResourceUri) + ?.toString() +} + +private fun vCardAvatarLabel(titleText: String?): String? { + return titleText + ?.trim() + ?.takeIf { it.isNotBlank() } + ?.first() + ?.uppercaseChar() + ?.toString() +} + @Composable private fun resolveTitleText( titleText: String?, diff --git a/src/com/android/messaging/ui/conversation/composer/ui/ConversationAttachmentPreview.kt b/src/com/android/messaging/ui/conversation/composer/ui/ConversationAttachmentPreview.kt index f42c27d3..6d54b803 100644 --- a/src/com/android/messaging/ui/conversation/composer/ui/ConversationAttachmentPreview.kt +++ b/src/com/android/messaging/ui/conversation/composer/ui/ConversationAttachmentPreview.kt @@ -369,6 +369,7 @@ private fun ConversationVCardAttachmentPreviewItem( .align(alignment = Alignment.CenterStart) .padding(horizontal = 16.dp, vertical = 12.dp), type = uiModel.type, + avatarUri = uiModel.avatarUri, titleText = uiModel.titleText, titleTextResId = uiModel.titleTextResId, subtitleText = uiModel.subtitleText, diff --git a/src/com/android/messaging/ui/conversation/messages/model/attachment/ConversationInlineAttachment.kt b/src/com/android/messaging/ui/conversation/messages/model/attachment/ConversationInlineAttachment.kt index ec353e98..d26d5e92 100644 --- a/src/com/android/messaging/ui/conversation/messages/model/attachment/ConversationInlineAttachment.kt +++ b/src/com/android/messaging/ui/conversation/messages/model/attachment/ConversationInlineAttachment.kt @@ -32,6 +32,7 @@ internal sealed interface ConversationInlineAttachment { val contentUri: String, override val openAction: ConversationAttachmentOpenAction?, val type: ConversationVCardAttachmentType, + val avatarUri: String?, val titleText: String?, val titleTextResId: Int?, val subtitleText: String?, diff --git a/src/com/android/messaging/ui/conversation/messages/ui/attachment/ConversationAttachmentSectionsBuilder.kt b/src/com/android/messaging/ui/conversation/messages/ui/attachment/ConversationAttachmentSectionsBuilder.kt index ea5f4ffe..7df42dc8 100644 --- a/src/com/android/messaging/ui/conversation/messages/ui/attachment/ConversationAttachmentSectionsBuilder.kt +++ b/src/com/android/messaging/ui/conversation/messages/ui/attachment/ConversationAttachmentSectionsBuilder.kt @@ -13,6 +13,7 @@ import kotlinx.collections.immutable.toImmutableList internal fun buildConversationAttachmentSections( attachments: ImmutableList, + vCardSubtitleTextResIdOverride: Int? = null, ): ConversationAttachmentSections { val galleryVisualAttachments = attachments .asSequence() @@ -22,7 +23,12 @@ internal fun buildConversationAttachmentSections( val trailingItems = attachments .asSequence() .filterNot(::isGalleryVisualAttachment) - .mapNotNull(::toConversationAttachmentItem) + .mapNotNull { attachment -> + toConversationAttachmentItem( + attachment = attachment, + vCardSubtitleTextResIdOverride = vCardSubtitleTextResIdOverride, + ) + } .toImmutableList() return ConversationAttachmentSections( @@ -35,8 +41,10 @@ private fun isGalleryVisualAttachment( attachment: ConversationMessageAttachment, ): Boolean { return when (attachment) { - is ConversationMessageAttachment.Media -> + is ConversationMessageAttachment.Media -> { attachment.part is ConversationMessagePartUiModel.Attachment.Image + } + is ConversationMessageAttachment.YouTubePreview -> true is ConversationMessageAttachment.Unsupported -> false } @@ -57,6 +65,7 @@ private fun isStandaloneVisualAttachment( private fun toConversationAttachmentItem( attachment: ConversationMessageAttachment, + vCardSubtitleTextResIdOverride: Int?, ): ConversationAttachmentItem? { return when { isStandaloneVisualAttachment(attachment = attachment) -> { @@ -67,13 +76,15 @@ private fun toConversationAttachmentItem( } isInlineAttachment(attachment = attachment) -> { - toInlineAttachment(attachment = attachment) - ?.let { inlineAttachment -> - ConversationAttachmentItem.Inline( - key = inlineAttachment.key, - attachment = inlineAttachment, - ) - } + toInlineAttachment( + attachment = attachment, + vCardSubtitleTextResIdOverride = vCardSubtitleTextResIdOverride, + )?.let { inlineAttachment -> + ConversationAttachmentItem.Inline( + key = inlineAttachment.key, + attachment = inlineAttachment, + ) + } } else -> null @@ -94,11 +105,13 @@ private fun isInlineAttachment( private fun toInlineAttachment( attachment: ConversationMessageAttachment, + vCardSubtitleTextResIdOverride: Int?, ): ConversationInlineAttachment? { return when (attachment) { is ConversationMessageAttachment.Media -> { toMediaInlineAttachment( attachment = attachment, + vCardSubtitleTextResIdOverride = vCardSubtitleTextResIdOverride, ) } @@ -116,6 +129,7 @@ private fun toInlineAttachment( private fun toMediaInlineAttachment( attachment: ConversationMessageAttachment.Media, + vCardSubtitleTextResIdOverride: Int?, ): ConversationInlineAttachment? { return when (val part = attachment.part) { is ConversationMessagePartUiModel.Attachment.Audio -> { @@ -132,6 +146,7 @@ private fun toMediaInlineAttachment( contentUri = part.contentUri.toString(), openAction = attachment.toConversationAttachmentOpenActionOrNull(), vCardUiModel = part.vCardUiModel, + subtitleTextResIdOverride = vCardSubtitleTextResIdOverride, ) } @@ -168,16 +183,21 @@ private fun createVCardInlineAttachment( contentUri: String, openAction: ConversationAttachmentOpenAction?, vCardUiModel: ConversationVCardAttachmentUiModel, + subtitleTextResIdOverride: Int?, ): ConversationInlineAttachment { return ConversationInlineAttachment.VCard( key = key, contentUri = contentUri, openAction = openAction, type = vCardUiModel.type, + avatarUri = vCardUiModel.avatarUri, titleText = vCardUiModel.titleText, titleTextResId = vCardUiModel.titleTextResId, - subtitleText = vCardUiModel.subtitleText, - subtitleTextResId = vCardUiModel.subtitleTextResId, + subtitleText = when { + subtitleTextResIdOverride == null -> vCardUiModel.subtitleText + else -> null + }, + subtitleTextResId = subtitleTextResIdOverride ?: vCardUiModel.subtitleTextResId, ) } diff --git a/src/com/android/messaging/ui/conversation/messages/ui/attachment/ConversationVCardInlineAttachmentRow.kt b/src/com/android/messaging/ui/conversation/messages/ui/attachment/ConversationVCardInlineAttachmentRow.kt index 9051ee0f..e72e03f6 100644 --- a/src/com/android/messaging/ui/conversation/messages/ui/attachment/ConversationVCardInlineAttachmentRow.kt +++ b/src/com/android/messaging/ui/conversation/messages/ui/attachment/ConversationVCardInlineAttachmentRow.kt @@ -69,6 +69,7 @@ internal fun ConversationVCardInlineAttachmentRowContent( .fillMaxWidth() .padding(horizontal = 14.dp, vertical = 12.dp), type = attachment.type, + avatarUri = attachment.avatarUri, titleText = attachment.titleText, titleTextResId = attachment.titleTextResId, subtitleText = attachment.subtitleText, 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 4e85e657..f29773d5 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 @@ -199,6 +199,7 @@ private fun rememberConversationMessageContent( } return remember( + message.canResendMessage, message.text, message.mmsSubject, message.parts, diff --git a/src/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessageContentBuilder.kt b/src/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessageContentBuilder.kt index 15d69169..7aeb1631 100644 --- a/src/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessageContentBuilder.kt +++ b/src/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessageContentBuilder.kt @@ -3,6 +3,7 @@ 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.R 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 @@ -17,7 +18,10 @@ internal fun buildConversationMessageContent( subjectText: String?, ): ConversationMessageContent { val attachments = buildConversationMessageAttachments(message = message) - val attachmentSections = buildConversationAttachmentSections(attachments = attachments) + val attachmentSections = buildConversationAttachmentSections( + attachments = attachments, + vCardSubtitleTextResIdOverride = vCardSubtitleTextResIdOverride(message), + ) val bodyText = buildConversationMessageBodyText( message = message, @@ -36,6 +40,13 @@ internal fun buildConversationMessageContent( ) } +private fun vCardSubtitleTextResIdOverride(message: ConversationMessageUiModel): Int? { + return when { + message.canResendMessage -> R.string.message_status_send_failed + else -> null + } +} + private fun buildConversationMessageAttachments( message: ConversationMessageUiModel, ): ImmutableList { From 409a22d8110a64526e3c2439ab10f0c287470bcd Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Thu, 14 May 2026 19:09:10 +0300 Subject: [PATCH 128/136] Reduce attachments spacing --- .../messages/ui/attachment/ConversationMessageAttachments.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/com/android/messaging/ui/conversation/messages/ui/attachment/ConversationMessageAttachments.kt b/src/com/android/messaging/ui/conversation/messages/ui/attachment/ConversationMessageAttachments.kt index e17e0fe4..7a67ae7e 100644 --- a/src/com/android/messaging/ui/conversation/messages/ui/attachment/ConversationMessageAttachments.kt +++ b/src/com/android/messaging/ui/conversation/messages/ui/attachment/ConversationMessageAttachments.kt @@ -30,7 +30,7 @@ internal fun ConversationMessageAttachments( Column( modifier = modifier, - verticalArrangement = Arrangement.spacedBy(space = 8.dp), + verticalArrangement = Arrangement.spacedBy(space = 2.dp), ) { if (hasGalleryVisualAttachments) { ConversationGalleryVisualAttachments( From 9447cb4624343278ea91a49a7d6682eda589c295 Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Thu, 14 May 2026 19:17:28 +0300 Subject: [PATCH 129/136] Improve message selection indicator layout and spacing --- .../messages/ui/message/ConversationMessageRows.kt | 10 +++++++--- .../message/ConversationMessageSelectionIndicator.kt | 6 ++++-- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/src/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessageRows.kt b/src/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessageRows.kt index 1a131381..4c2c2863 100644 --- a/src/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessageRows.kt +++ b/src/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessageRows.kt @@ -143,7 +143,7 @@ private fun ConversationMessageAvatarGutter( onMessageClick: () -> Unit, onMessageLongClick: () -> Unit, ) { - if (!layout.showAvatarGutter) { + if (isSelectionMode || !layout.showAvatarGutter) { return } @@ -269,7 +269,10 @@ internal fun ConversationMessageMetadataRow( message = message, ), ) { - ConversationMessageAvatarMetadataOffset(layout = layout) + ConversationMessageAvatarMetadataOffset( + isSelectionMode = isSelectionMode, + layout = layout, + ) Column( modifier = Modifier.widthIn(max = maxBubbleWidth), @@ -291,9 +294,10 @@ internal fun ConversationMessageMetadataRow( @Composable private fun ConversationMessageAvatarMetadataOffset( + isSelectionMode: Boolean, layout: ConversationMessageLayout, ) { - if (!layout.showAvatarGutter) { + if (isSelectionMode || !layout.showAvatarGutter) { return } diff --git a/src/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessageSelectionIndicator.kt b/src/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessageSelectionIndicator.kt index af4c4e4a..85aea618 100644 --- a/src/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessageSelectionIndicator.kt +++ b/src/com/android/messaging/ui/conversation/messages/ui/message/ConversationMessageSelectionIndicator.kt @@ -36,8 +36,10 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.unit.dp -private val MESSAGE_SELECTION_INDICATOR_GUTTER_WIDTH = 48.dp private val MESSAGE_SELECTION_INDICATOR_SIZE = 22.dp +private val MESSAGE_SELECTION_INDICATOR_HORIZONTAL_GAP = 16.dp +private val MESSAGE_SELECTION_INDICATOR_GUTTER_WIDTH = MESSAGE_SELECTION_INDICATOR_SIZE + + MESSAGE_SELECTION_INDICATOR_HORIZONTAL_GAP private val MESSAGE_SELECTION_INDICATOR_CHECK_SIZE = 16.dp private val MESSAGE_SELECTION_INDICATOR_BORDER_WIDTH = 2.dp @@ -112,7 +114,7 @@ private fun ConversationMessageSelectionIndicatorContent( modifier = Modifier .width(width = MESSAGE_SELECTION_INDICATOR_GUTTER_WIDTH) .height(height = MESSAGE_SELECTION_INDICATOR_SIZE), - contentAlignment = Alignment.Center, + contentAlignment = Alignment.CenterStart, ) { Box( modifier = Modifier From 943db6b51a366334674ea33006ab47fd555a4862 Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Thu, 14 May 2026 20:02:43 +0300 Subject: [PATCH 130/136] Replace legacy "stop recording" icon with a Compose one --- .../ui/ConversationLegacyCaptureStopIcon.kt | 55 +++++++++++++++++++ .../ui/ConversationSendActionButton.kt | 3 +- 2 files changed, 56 insertions(+), 2 deletions(-) create mode 100644 src/com/android/messaging/ui/conversation/composer/ui/ConversationLegacyCaptureStopIcon.kt diff --git a/src/com/android/messaging/ui/conversation/composer/ui/ConversationLegacyCaptureStopIcon.kt b/src/com/android/messaging/ui/conversation/composer/ui/ConversationLegacyCaptureStopIcon.kt new file mode 100644 index 00000000..98dc45fd --- /dev/null +++ b/src/com/android/messaging/ui/conversation/composer/ui/ConversationLegacyCaptureStopIcon.kt @@ -0,0 +1,55 @@ +package com.android.messaging.ui.conversation.composer.ui + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +internal val CaptureStopIcon: ImageVector + get() { + val cachedIcon = cachedCaptureStopIcon + if (cachedIcon != null) { + return cachedIcon + } + + val icon = ImageVector.Builder( + name = "capture_stop", + defaultWidth = 61.5.dp, + defaultHeight = 61.5.dp, + viewportWidth = 246f, + viewportHeight = 246f, + ).apply { + path( + stroke = SolidColor(Color.Black), + strokeLineWidth = 12f, + ) { + moveTo(123f, 17.5f) + curveTo(181.266f, 17.5f, 228.5f, 64.734f, 228.5f, 123f) + curveTo(228.5f, 181.266f, 181.266f, 228.5f, 123f, 228.5f) + curveTo(64.734f, 228.5f, 17.5f, 181.266f, 17.5f, 123f) + curveTo(17.5f, 64.734f, 64.734f, 17.5f, 123f, 17.5f) + close() + } + + path( + fill = SolidColor(Color.Black), + ) { + moveTo(94f, 90f) + horizontalLineTo(152f) + curveTo(154.209f, 90f, 156f, 91.791f, 156f, 94f) + verticalLineTo(152f) + curveTo(156f, 154.209f, 154.209f, 156f, 152f, 156f) + horizontalLineTo(94f) + curveTo(91.791f, 156f, 90f, 154.209f, 90f, 152f) + verticalLineTo(94f) + curveTo(90f, 91.791f, 91.791f, 90f, 94f, 90f) + close() + } + }.build() + + cachedCaptureStopIcon = icon + return icon + } + +private var cachedCaptureStopIcon: ImageVector? = null diff --git a/src/com/android/messaging/ui/conversation/composer/ui/ConversationSendActionButton.kt b/src/com/android/messaging/ui/conversation/composer/ui/ConversationSendActionButton.kt index 05421218..1f578798 100644 --- a/src/com/android/messaging/ui/conversation/composer/ui/ConversationSendActionButton.kt +++ b/src/com/android/messaging/ui/conversation/composer/ui/ConversationSendActionButton.kt @@ -41,7 +41,6 @@ import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.scale import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.onClick @@ -370,7 +369,7 @@ private fun ConversationSendActionButtonIcon( ConversationSendActionButtonMode.Stop -> { Icon( - painter = painterResource(id = R.drawable.ic_mp_capture_stop_large_light), + imageVector = CaptureStopIcon, contentDescription = stringResource( id = R.string.audio_record_stop_content_description, ), From 91154f6c727857428cd887d83f871d60aebbb09b Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Thu, 14 May 2026 20:23:27 +0300 Subject: [PATCH 131/136] Remove unused legacy code --- res/drawable-hdpi/ic_camera_front_light.png | Bin 1126 -> 0 bytes res/drawable-hdpi/ic_camera_light.png | Bin 1116 -> 0 bytes res/drawable-hdpi/ic_camera_rear_light.png | Bin 935 -> 0 bytes .../ic_checkbox_outline_light.png | Bin 1030 -> 0 bytes res/drawable-hdpi/ic_image_light.png | Bin 863 -> 0 bytes res/drawable-hdpi/ic_mp_audio_mic.png | Bin 1505 -> 0 bytes .../ic_mp_camera_small_light.png | Bin 2066 -> 0 bytes .../ic_mp_capture_stop_large_light.png | Bin 8312 -> 0 bytes res/drawable-hdpi/ic_mp_full_screen_light.png | Bin 2031 -> 0 bytes res/drawable-hdpi/ic_mp_video_large_light.png | Bin 2087 -> 0 bytes res/drawable-hdpi/ic_mp_video_small_light.png | Bin 1286 -> 0 bytes res/drawable-mdpi/ic_camera_front_light.png | Bin 843 -> 0 bytes res/drawable-mdpi/ic_camera_light.png | Bin 844 -> 0 bytes res/drawable-mdpi/ic_camera_rear_light.png | Bin 726 -> 0 bytes .../ic_checkbox_outline_light.png | Bin 776 -> 0 bytes res/drawable-mdpi/ic_image_light.png | Bin 697 -> 0 bytes res/drawable-mdpi/ic_mp_audio_mic.png | Bin 1094 -> 0 bytes .../ic_mp_camera_small_light.png | Bin 1305 -> 0 bytes .../ic_mp_capture_stop_large_light.png | Bin 5095 -> 0 bytes res/drawable-mdpi/ic_mp_full_screen_light.png | Bin 1280 -> 0 bytes res/drawable-mdpi/ic_mp_video_large_light.png | Bin 1451 -> 0 bytes res/drawable-mdpi/ic_mp_video_small_light.png | Bin 925 -> 0 bytes res/drawable-xhdpi/ic_camera_front_light.png | Bin 1357 -> 0 bytes res/drawable-xhdpi/ic_camera_light.png | Bin 1481 -> 0 bytes res/drawable-xhdpi/ic_camera_rear_light.png | Bin 1091 -> 0 bytes .../ic_checkbox_outline_light.png | Bin 1199 -> 0 bytes res/drawable-xhdpi/ic_image_light.png | Bin 1131 -> 0 bytes res/drawable-xhdpi/ic_mp_audio_mic.png | Bin 2005 -> 0 bytes .../ic_mp_camera_small_light.png | Bin 2690 -> 0 bytes .../ic_mp_capture_stop_large_light.png | Bin 11858 -> 0 bytes .../ic_mp_full_screen_light.png | Bin 2514 -> 0 bytes .../ic_mp_video_large_light.png | Bin 2739 -> 0 bytes .../ic_mp_video_small_light.png | Bin 1667 -> 0 bytes res/drawable-xxhdpi/ic_camera_front_light.png | Bin 1859 -> 0 bytes res/drawable-xxhdpi/ic_camera_light.png | Bin 1886 -> 0 bytes res/drawable-xxhdpi/ic_camera_rear_light.png | Bin 1534 -> 0 bytes .../ic_checkbox_outline_light.png | Bin 1576 -> 0 bytes res/drawable-xxhdpi/ic_image_light.png | Bin 1669 -> 0 bytes res/drawable-xxhdpi/ic_mp_audio_mic.png | Bin 2446 -> 0 bytes .../ic_mp_camera_small_light.png | Bin 3444 -> 0 bytes .../ic_mp_capture_stop_large_light.png | Bin 11969 -> 0 bytes .../ic_mp_full_screen_light.png | Bin 2823 -> 0 bytes .../ic_mp_video_large_light.png | Bin 3596 -> 0 bytes .../ic_mp_video_small_light.png | Bin 2335 -> 0 bytes .../ic_camera_front_light.png | Bin 1878 -> 0 bytes res/drawable-xxxhdpi/ic_camera_light.png | Bin 2333 -> 0 bytes res/drawable-xxxhdpi/ic_camera_rear_light.png | Bin 1700 -> 0 bytes .../ic_checkbox_outline_light.png | Bin 1695 -> 0 bytes res/drawable-xxxhdpi/ic_image_light.png | Bin 1945 -> 0 bytes res/drawable-xxxhdpi/ic_mp_audio_mic.png | Bin 3012 -> 0 bytes .../ic_mp_camera_small_light.png | Bin 4455 -> 0 bytes .../ic_mp_capture_stop_large_light.png | Bin 18106 -> 0 bytes .../ic_mp_full_screen_light.png | Bin 3502 -> 0 bytes .../ic_mp_video_large_light.png | Bin 4610 -> 0 bytes .../ic_mp_video_small_light.png | Bin 2938 -> 0 bytes ...audio_record_control_button_background.xml | 26 - .../gallery_image_background_selector.xml | 21 - .../mediapicker_tab_button_background.xml | 18 - res/layout/gallery_grid_item_view.xml | 103 -- res/layout/mediapicker_audio_chooser.xml | 96 -- res/layout/mediapicker_camera_chooser.xml | 150 -- res/layout/mediapicker_contact_chooser.xml | 61 - res/layout/mediapicker_fragment.xml | 34 - res/layout/mediapicker_gallery_chooser.xml | 44 - res/layout/mediapicker_location_container.xml | 31 - res/layout/mediapicker_tab_button.xml | 24 - res/menu/gallery_picker_menu.xml | 34 - res/values-af/strings.xml | 22 - res/values-am/strings.xml | 22 - res/values-ar/strings.xml | 22 - res/values-az/strings.xml | 22 - res/values-bg/strings.xml | 22 - res/values-bn/strings.xml | 22 - res/values-ca/strings.xml | 22 - res/values-cs/strings.xml | 22 - res/values-da/strings.xml | 22 - res/values-de/strings.xml | 22 - res/values-el/strings.xml | 22 - res/values-en-rAU/strings.xml | 22 - res/values-en-rGB/strings.xml | 22 - res/values-en-rIN/strings.xml | 22 - res/values-es-rUS/strings.xml | 22 - res/values-es/strings.xml | 22 - res/values-et/strings.xml | 22 - res/values-eu/strings.xml | 22 - res/values-fa/strings.xml | 22 - res/values-fi/strings.xml | 22 - res/values-fr-rCA/strings.xml | 22 - res/values-fr/strings.xml | 22 - res/values-gl/strings.xml | 22 - res/values-gu/strings.xml | 22 - res/values-hi/strings.xml | 22 - res/values-hr/strings.xml | 22 - res/values-hu/strings.xml | 22 - res/values-hy/strings.xml | 22 - res/values-in/strings.xml | 22 - res/values-is/strings.xml | 22 - res/values-it/strings.xml | 22 - res/values-iw/strings.xml | 22 - res/values-ja/strings.xml | 22 - res/values-ka/strings.xml | 22 - res/values-kk/strings.xml | 22 - res/values-km/strings.xml | 22 - res/values-kn/strings.xml | 22 - res/values-ko/strings.xml | 22 - res/values-ky/strings.xml | 22 - res/values-ldrtl/styles.xml | 4 - res/values-lo/strings.xml | 22 - res/values-lt/strings.xml | 22 - res/values-lv/strings.xml | 22 - res/values-mk/strings.xml | 22 - res/values-ml/strings.xml | 22 - res/values-mn/strings.xml | 22 - res/values-mr/strings.xml | 22 - res/values-ms/strings.xml | 22 - res/values-my/strings.xml | 22 - res/values-nb/strings.xml | 22 - res/values-ne/strings.xml | 22 - res/values-nl/strings.xml | 22 - res/values-pa/strings.xml | 22 - res/values-pl/strings.xml | 22 - res/values-pt-rPT/strings.xml | 22 - res/values-pt/strings.xml | 22 - res/values-ro/strings.xml | 22 - res/values-ru/strings.xml | 22 - res/values-si/strings.xml | 22 - res/values-sk/strings.xml | 22 - res/values-sl/strings.xml | 22 - res/values-sq/strings.xml | 22 - res/values-sr/strings.xml | 22 - res/values-sv/strings.xml | 22 - res/values-sw/strings.xml | 22 - res/values-ta/strings.xml | 22 - res/values-te/strings.xml | 22 - res/values-th/strings.xml | 22 - res/values-tl/strings.xml | 22 - res/values-tr/strings.xml | 22 - res/values-uk/strings.xml | 22 - res/values-ur/strings.xml | 22 - res/values-uz/strings.xml | 22 - res/values-vi/strings.xml | 22 - res/values-zh-rCN/strings.xml | 22 - res/values-zh-rHK/strings.xml | 22 - res/values-zh-rTW/strings.xml | 22 - res/values-zu/strings.xml | 22 - res/values/attrs.xml | 5 - res/values/colors.xml | 8 - res/values/constants.xml | 3 - res/values/dimens.xml | 8 - res/values/strings.xml | 30 - res/values/styles.xml | 14 - src/com/android/messaging/ui/UIIntents.java | 22 - .../android/messaging/ui/UIIntentsImpl.java | 24 - .../ui/mediapicker/AudioLevelSource.java | 73 - .../ui/mediapicker/AudioMediaChooser.java | 130 -- .../ui/mediapicker/AudioRecordView.java | 351 ----- .../ui/mediapicker/CameraManager.java | 1201 ----------------- .../ui/mediapicker/CameraMediaChooser.java | 481 ------- .../mediapicker/CameraMediaChooserView.java | 105 -- .../ui/mediapicker/CameraPreview.java | 152 --- .../ui/mediapicker/ContactMediaChooser.java | 144 -- .../ui/mediapicker/DocumentImagePicker.java | 138 -- .../ui/mediapicker/GalleryGridAdapter.java | 62 - .../ui/mediapicker/GalleryGridItemView.java | 203 --- .../ui/mediapicker/GalleryGridView.java | 316 ----- .../ui/mediapicker/GalleryMediaChooser.java | 264 ---- .../ui/mediapicker/HardwareCameraPreview.java | 118 -- .../ui/mediapicker/ImagePersistTask.java | 172 --- .../LevelTrackingMediaRecorder.java | 74 +- .../ui/mediapicker/MediaChooser.java | 220 --- .../messaging/ui/mediapicker/MediaPicker.java | 719 ---------- .../ui/mediapicker/MediaPickerGridView.java | 44 - .../ui/mediapicker/MediaPickerPanel.java | 555 -------- .../ui/mediapicker/MmsVideoRecorder.java | 142 -- .../ui/mediapicker/SoftwareCameraPreview.java | 114 -- .../messaging/ui/mediapicker/SoundLevels.java | 212 --- .../camerafocus/FocusIndicator.java | 24 - .../camerafocus/FocusOverlayManager.java | 589 -------- .../camerafocus/OverlayRenderer.java | 95 -- .../ui/mediapicker/camerafocus/PieItem.java | 202 --- .../mediapicker/camerafocus/PieRenderer.java | 825 ----------- .../ui/mediapicker/camerafocus/README.txt | 3 - .../camerafocus/RenderOverlay.java | 178 --- 183 files changed, 1 insertion(+), 10359 deletions(-) delete mode 100644 res/drawable-hdpi/ic_camera_front_light.png delete mode 100644 res/drawable-hdpi/ic_camera_light.png delete mode 100644 res/drawable-hdpi/ic_camera_rear_light.png delete mode 100644 res/drawable-hdpi/ic_checkbox_outline_light.png delete mode 100644 res/drawable-hdpi/ic_image_light.png delete mode 100644 res/drawable-hdpi/ic_mp_audio_mic.png delete mode 100644 res/drawable-hdpi/ic_mp_camera_small_light.png delete mode 100644 res/drawable-hdpi/ic_mp_capture_stop_large_light.png delete mode 100644 res/drawable-hdpi/ic_mp_full_screen_light.png delete mode 100644 res/drawable-hdpi/ic_mp_video_large_light.png delete mode 100644 res/drawable-hdpi/ic_mp_video_small_light.png delete mode 100644 res/drawable-mdpi/ic_camera_front_light.png delete mode 100644 res/drawable-mdpi/ic_camera_light.png delete mode 100644 res/drawable-mdpi/ic_camera_rear_light.png delete mode 100644 res/drawable-mdpi/ic_checkbox_outline_light.png delete mode 100644 res/drawable-mdpi/ic_image_light.png delete mode 100644 res/drawable-mdpi/ic_mp_audio_mic.png delete mode 100644 res/drawable-mdpi/ic_mp_camera_small_light.png delete mode 100644 res/drawable-mdpi/ic_mp_capture_stop_large_light.png delete mode 100644 res/drawable-mdpi/ic_mp_full_screen_light.png delete mode 100644 res/drawable-mdpi/ic_mp_video_large_light.png delete mode 100644 res/drawable-mdpi/ic_mp_video_small_light.png delete mode 100644 res/drawable-xhdpi/ic_camera_front_light.png delete mode 100644 res/drawable-xhdpi/ic_camera_light.png delete mode 100644 res/drawable-xhdpi/ic_camera_rear_light.png delete mode 100644 res/drawable-xhdpi/ic_checkbox_outline_light.png delete mode 100644 res/drawable-xhdpi/ic_image_light.png delete mode 100644 res/drawable-xhdpi/ic_mp_audio_mic.png delete mode 100644 res/drawable-xhdpi/ic_mp_camera_small_light.png delete mode 100644 res/drawable-xhdpi/ic_mp_capture_stop_large_light.png delete mode 100644 res/drawable-xhdpi/ic_mp_full_screen_light.png delete mode 100644 res/drawable-xhdpi/ic_mp_video_large_light.png delete mode 100644 res/drawable-xhdpi/ic_mp_video_small_light.png delete mode 100644 res/drawable-xxhdpi/ic_camera_front_light.png delete mode 100644 res/drawable-xxhdpi/ic_camera_light.png delete mode 100644 res/drawable-xxhdpi/ic_camera_rear_light.png delete mode 100644 res/drawable-xxhdpi/ic_checkbox_outline_light.png delete mode 100644 res/drawable-xxhdpi/ic_image_light.png delete mode 100644 res/drawable-xxhdpi/ic_mp_audio_mic.png delete mode 100644 res/drawable-xxhdpi/ic_mp_camera_small_light.png delete mode 100644 res/drawable-xxhdpi/ic_mp_capture_stop_large_light.png delete mode 100644 res/drawable-xxhdpi/ic_mp_full_screen_light.png delete mode 100644 res/drawable-xxhdpi/ic_mp_video_large_light.png delete mode 100644 res/drawable-xxhdpi/ic_mp_video_small_light.png delete mode 100644 res/drawable-xxxhdpi/ic_camera_front_light.png delete mode 100644 res/drawable-xxxhdpi/ic_camera_light.png delete mode 100644 res/drawable-xxxhdpi/ic_camera_rear_light.png delete mode 100644 res/drawable-xxxhdpi/ic_checkbox_outline_light.png delete mode 100644 res/drawable-xxxhdpi/ic_image_light.png delete mode 100644 res/drawable-xxxhdpi/ic_mp_audio_mic.png delete mode 100644 res/drawable-xxxhdpi/ic_mp_camera_small_light.png delete mode 100644 res/drawable-xxxhdpi/ic_mp_capture_stop_large_light.png delete mode 100644 res/drawable-xxxhdpi/ic_mp_full_screen_light.png delete mode 100644 res/drawable-xxxhdpi/ic_mp_video_large_light.png delete mode 100644 res/drawable-xxxhdpi/ic_mp_video_small_light.png delete mode 100644 res/drawable/audio_record_control_button_background.xml delete mode 100644 res/drawable/gallery_image_background_selector.xml delete mode 100644 res/drawable/mediapicker_tab_button_background.xml delete mode 100644 res/layout/gallery_grid_item_view.xml delete mode 100644 res/layout/mediapicker_audio_chooser.xml delete mode 100644 res/layout/mediapicker_camera_chooser.xml delete mode 100644 res/layout/mediapicker_contact_chooser.xml delete mode 100644 res/layout/mediapicker_fragment.xml delete mode 100644 res/layout/mediapicker_gallery_chooser.xml delete mode 100644 res/layout/mediapicker_location_container.xml delete mode 100644 res/layout/mediapicker_tab_button.xml delete mode 100644 res/menu/gallery_picker_menu.xml delete mode 100644 src/com/android/messaging/ui/mediapicker/AudioLevelSource.java delete mode 100644 src/com/android/messaging/ui/mediapicker/AudioMediaChooser.java delete mode 100644 src/com/android/messaging/ui/mediapicker/AudioRecordView.java delete mode 100644 src/com/android/messaging/ui/mediapicker/CameraManager.java delete mode 100644 src/com/android/messaging/ui/mediapicker/CameraMediaChooser.java delete mode 100644 src/com/android/messaging/ui/mediapicker/CameraMediaChooserView.java delete mode 100644 src/com/android/messaging/ui/mediapicker/CameraPreview.java delete mode 100644 src/com/android/messaging/ui/mediapicker/ContactMediaChooser.java delete mode 100644 src/com/android/messaging/ui/mediapicker/DocumentImagePicker.java delete mode 100644 src/com/android/messaging/ui/mediapicker/GalleryGridAdapter.java delete mode 100644 src/com/android/messaging/ui/mediapicker/GalleryGridItemView.java delete mode 100644 src/com/android/messaging/ui/mediapicker/GalleryGridView.java delete mode 100644 src/com/android/messaging/ui/mediapicker/GalleryMediaChooser.java delete mode 100644 src/com/android/messaging/ui/mediapicker/HardwareCameraPreview.java delete mode 100644 src/com/android/messaging/ui/mediapicker/ImagePersistTask.java delete mode 100644 src/com/android/messaging/ui/mediapicker/MediaChooser.java delete mode 100644 src/com/android/messaging/ui/mediapicker/MediaPicker.java delete mode 100644 src/com/android/messaging/ui/mediapicker/MediaPickerGridView.java delete mode 100644 src/com/android/messaging/ui/mediapicker/MediaPickerPanel.java delete mode 100644 src/com/android/messaging/ui/mediapicker/MmsVideoRecorder.java delete mode 100644 src/com/android/messaging/ui/mediapicker/SoftwareCameraPreview.java delete mode 100644 src/com/android/messaging/ui/mediapicker/SoundLevels.java delete mode 100644 src/com/android/messaging/ui/mediapicker/camerafocus/FocusIndicator.java delete mode 100644 src/com/android/messaging/ui/mediapicker/camerafocus/FocusOverlayManager.java delete mode 100644 src/com/android/messaging/ui/mediapicker/camerafocus/OverlayRenderer.java delete mode 100644 src/com/android/messaging/ui/mediapicker/camerafocus/PieItem.java delete mode 100644 src/com/android/messaging/ui/mediapicker/camerafocus/PieRenderer.java delete mode 100644 src/com/android/messaging/ui/mediapicker/camerafocus/README.txt delete mode 100644 src/com/android/messaging/ui/mediapicker/camerafocus/RenderOverlay.java diff --git a/res/drawable-hdpi/ic_camera_front_light.png b/res/drawable-hdpi/ic_camera_front_light.png deleted file mode 100644 index c0ddaf6ddc55980df7091037365519c59271620f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1126 zcmeAS@N?(olHy`uVBq!ia0vp^Dj>|k1|%Oc%$NbBSkfJR9T^xl_H+M9WCijWi-X*q z7}lMWc?skwBzpw;GB8xBF)%c=FfjZA3N^f7U???UV0e|lz+eS5K)hhiu0R{01Y44~ zy9>jA5L~c#`DCC7XMsm#F_88EW4Dvpb_@*6W}YsNArXhS&e)$F;wW(3{`b42Bpyql z4T5WWmIPL3I|@4LY`Ji%-XZsj1nb3P8c{3$J9Aw;6~uGvCdV|h&V+0ieU-SL@5wr4 zX8E6QOut>bQ##^a_4l;>pXXL?-fYdzC@AQna{0BSV5FFKtl&%4zIEqf!`9@!$@qBU zOovA>V|7sMojc2tl$0V*l?ZEuv#pn0~M|P+EWj-dddEt!nHqwDDb7Vez zna6D2*z9#d+lSFNm|CP#rVEVzfhC#paqR?Fp&(4606ZOo@*S)qqU}{>Y zeDS82U{TVP0~Q5_AN~oM74FttP_1YZveL<81Lr-ado1_%v;CW(-=wl~mh-v=XIBSL zpZY@kLErMpZwf-4@~-A23jdgAmmd(P((a|9yx!bawQ17H6R(Buy?0g)QNDkh!EJ*! zUryXs+q)C5WGodZ`gwt|kNJ7yK8VLOQGL-pUjMlFAH(G#&%=+TFtb~_%tsL$9v{;ndULMbNynA-s^s|a)bVF z8|?$S2`3ZVt)}1Q__^fO4Yl)cpF|%>e4te@f6w(vLU-ggwCVQU=1ua^7X575+g20g z5~cLXV}9?FFRxXFe6si}L~g`?xRuZ^5q#rXRb2h-uju^QQ0b-s2Tje!?3pv93#AXd?T)_>#2g zmj4pWFa2k<>0fP=GVjv_U{+Esag8WRNi0dVN-jzTQVd20hNij(hPnn8A%-SaCMH$} zMnJZefx*}BrngZv|k1|%Oc%$NbBSkfJR9T^xl_H+M9WCijWi-X*q z7}lMWc?skwBzpw;GB8xBF)%c=FfjZA3N^f7U???UV0e|lz+eS5K)hhiu0R{01Y44~ zy9>jA5L~c#`DCC7XMsm#F_88EW4Dvpb_@*6I-V|$ArXh)&e)$F;wW(3zWTeK+r~}- z7eSphi&hA1@m#2&m>`_Y<=E6!-}#Tf%xuvkO~!f^GbxvzCf@*q1x;pRN;g#Is_iT{ z7qgyxyZYYeZ?W@wEjGN_eYev1{>-^E&8t7m|L3#r_M(oK-&bY!i3l(}-_@nb9>B&E zx%A+^k5jl-IeRTw!?dMg?g7?>{;DG{GZ@Mn^j{rJyE)IvB`2grPGq9{qm`Puo1ULn zDsre~cUf}nxQEM;neQSDq+^d&i3d!zlYQV6pqH?rLW1QTWBQjFlVoQ4+OG3IkXx(g zb^1W^)ysCD7<844R$nt;V!l)}j*)pWanh>YiCU zJN|{;srk>Qa9%DBt<2tNc;(7YzCB^y`3&kG4&LE$W42g%JT&xQm7Aa5QKn6^OqPGH zwCxj_AKCdSV%72knopT$oIi0>NZ}y+JO2l3*ZI0g8Lj&)U0c67_U5YgfYsq&Dz~4j z48589BkbjxXGY%5dU(1-cyO3cd5zE{}=EycDhP)q;3y+a0Mn$F*_ zmECnQZ(rLo?O{r5WIwQ}E~33b`A6GpnSG*~SDLRdP`(kYX@0Ff`FMFx53Q3o$gbGBLC=HrF*Uw=yue?5G%m zq9HdwB{QuOw}wSBGl7|!K@wy`aDG}zd16s2gJVj5QmTSyZen_BP-|k1|%Oc%$NbBSkfJR9T^xl_H+M9WCijWi-X*q z7}lMWc?skwBzpw;GB8xBF)%c=FfjZA3N^f7U???UV0e|lz+eS5K)hhiu0R{01Y44~ zy9>jA5L~c#`DCC7XMsm#F_88EW4Dvpb_@(m<(@8%ArXh)&T!1T>>$y)-;+s~#dMLk zO4t_l#EZJS^==e!ikxC%6lytSD{zPL2&)B?PDFTt0;r+oKMcb~hGw>^7qJn>w- zac=#+|Ig0e_?YkEAT-J9n!Lv^rGBSO5!3xAE%=h&9qTyq##1W1LsRAK;?I??b9%D% zJG`F0OEGDE{k?(FfH_8O8}}UM{ckK-(`A?+pLuqUlk0_hi@490f93`(-`3i0J5tD_ z+Gi*FBJ|7(uGXC&St?{LjW=!7Uo=BzlI1HIEo-5^!$y1kRVvthbloKTbY^j_a<+*! z^}RPufnBE|`#_@85ycI;hNa&1oO@H*r>K3p^K1LH`|R`98s41B+B!3ywNF{__QYWC z-JAOsNN@`a^q2MC9Dn4<-|TSYC52w&UlCQ9z(g;N521y z_NQ(P<>UInWy4~}miJt5`FbId?pF`H_w@D7WZK@y{v&9GysF&kK+~+J|S-WY@ zIi33xOVnO2ox6MKtFJthW?L!UI}|SdagB3ijc~l3{LQV(2Rw@o@cqpCen%$n-Gn9c z&Q@j2{^)Y4KSq z{Ce6Jjoz7(KhyujNj=UO_QmvAUQh^kMk%6J9u7RPhfklX+iIs_o zm4OkEZDnBa^}Fe96b-rgDVb@NxHYt_>P-h~kObKfoS#-wo>-L1;Fyx1l&avFo0y&& Yl$w}QS$HxPlwlY=UHx3vIVCg!02w)UbpQYW diff --git a/res/drawable-hdpi/ic_checkbox_outline_light.png b/res/drawable-hdpi/ic_checkbox_outline_light.png deleted file mode 100644 index 44ed34efe403218cded90687de5f243e997a43c2..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1030 zcmeAS@N?(olHy`uVBq!ia0vp^Dj>|k1|%Oc%$NbBSkfJR9T^xl_H+M9WCijWi-X*q z7}lMWc?skwBzpw;GB8xBF)%c=FfjZA3N^f7U???UV0e|lz+eS5K)hhiu0R{01Y44~ zy9>jA5L~c#`DCC7XMsm#F_88EW4Dvpb_@(m*F0SuLn02pov|}d#8JQ@{`{Gw4@sOq zxP(~j8#c5gT;~v&&hbM~;UK3|4V#BRp$f;5i8J&WjCy!aXexGGo~PTi^3%0%>z+p( zbJ5GZdi-3--j%D?&6B$^v52!yL8;EJ_kX^Ir2zxS!hN&OYAZ`HWUk7www=Pbyzy}G z3mJy$#`FW(3Y>~ObKV6uylC9aX~0n4sJ+T2lVb~CNrODsl&%v8{?1=;Pyfs*jw$BT zvZ@^ui^`LulcpJ8l}$=ddH2XgkH|%K<@Z)8i)~VseSYp5+XjX^EPBkVGdzxO z>F{uO5t!&Id1}$Q(pkHl-fxaymh11bZ`C_fLCY1NXHPt(^(9KeI$AZgb$QYAnUfDN zFL-^(d|S)vhBJ3xE4F;yHLvqaK11yT#SOESOzoI#bKRY;m9YNR_E{xRn;V?4L}d%x z6@j$SVp&o%p5eb%$2iM#T8HQec1))?CAv*;i1 zgE@ze@C&bBX3Lj$u<{xIE)OS;W&x+g6Mpu7Gb<4N!4|_3XUUP>&)hG$Zkhi%W7)3U zRJj)`)8{|_wp-1CyU^9_e?M^G&GCV2cQ5SS=s>fUh9-_PTSw4N?Vta*EH? z-CZKLV&nM(oDZ7My=Tv3{9TeGdgIU*#r{74x4f+rs8%3TFsiF>)1J zA0W5vTjQO{RVzLp;O@F*BDO5^-tp{w*)*5e?tT+LyuWJvqGFYS@6&~x^ES0?w_P_i z*z2`^hU)dmrGBU1<^N?}%a)OOD4g*u^Zmx1FEosMRYZP2sEJiD{&CLs-nl;6QSWW{%RSF?XHAh^dY-|3MZH|M&@S@>!fI1G|Nq;lxXD;%#oV`l zWP;Vo(xe-2PPFmdKI;Vst0NBN%wEzGB diff --git a/res/drawable-hdpi/ic_image_light.png b/res/drawable-hdpi/ic_image_light.png deleted file mode 100644 index 744f9201d46f7a9c912bb6bda87818ed5758ae0c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 863 zcmeAS@N?(olHy`uVBq!ia0vp^Dj>|k1|%Oc%$NbBSkfJR9T^xl_H+M9WCijWi-X*q z7}lMWc?skwBzpw;GB8xBF)%c=FfjZA3N^f7U???UV0e|lz+eS5K)hhiu0R{01Y44~ zy9>jA5L~c#`DCC7XMsm#F_88EW4Dvpb_@(m`kpS1ArXh)&e)$V>L_sBzPeMNMTP6z zG_h&Z*qF2OAIB%CZ*$^eRa_d@l<`7u6z<|?u)rZ{~T-M(Ev^Yq^^B^6D+`_c)* z7CwtD)}LV%P7uFQG)`~#NR{j4GlngxP4iWjU=Jl{Q`Ykp40RKrxxEi6WY z%;uL38)TMziu@t&(Av0%z3k0Si4@PE4Y@VWm$p3I9O!W>+K&7B*=y^z_NTw_Jv%@D z%wFXyQ~K`wl4oAO_PyPQ*`FjXYu45_*vP(bc&j42)ADb|%BD{lpBXEJdgn)<`ctR! za@Nbe9lumw23)%O;>v?>*Hmkb=I+r=jcu&qwfS;z(mC&6GHbitfBohVw!c5e@Svju z-{(^$PETHRz1-vLu%fX_%s0;DOHO~Y^Om_gam&m-#j`iw<({-pRq#FU6Xy4B zuOIr9_$ux2{G_+GQZI;q z4qyCnzi1cV(Il^z%%_5WS$k^Aig$ff_ z{FKbJO57S2$;4Z-6g|IU->gI+~HY&~Hjj$(hSJxl~H$?SDKtu!xDX{&Y-q;>wBrdM!5PM03gE%0O<E7B7* z2|zL)fT=J5h@}8v;x9coxL5j+58qEEgY}KM`mnlKdQpsb4NR1>>vyfOjB zzZl`i^8M^>6pb^FR?8>JNHltzO6M{1D{>|jiPYnHW7OtY=af;HRwo3h%jFPZV_g5IyBFRGk0H|DElSWAKBfl@$Lq zvA=({Qcz>eKo#XY`hsU~=ecf*zb760g+#if%EkMiv!Ypk|F*H^&qE z6>hMueXFkU1+VhkmC1ieV5`t})-DmYPKW1_mukxQ%84xJVzYJUh;@OLw;4^9^13o1 zt$|MNA9oHlr@UmVLN>oFdFP>LY{>tFS=3R+B!dH<9wB>}i?bjO9))t8MTI*B^gJD@ zl`Y@rNp@xV-WGJriqGhu{QDu?9cuat%ZhEb zd_PNmf1y{@irZNt<#NmOzJ9LR_(fdMd=JuchX2Qrp7oMU&BpNn)1dhpbWM5Y?XqV+ z#}iNkyNtxs7s@3y6#lbAhOgN*^=)@B{yc)!?6fh5e$1nE0=IlrYXC3s9TETiby%iS zKb^N7iguqRscEfi?n`B6HM{G3D2dcWGFigOCn{9V`zfvJkl~l9 zz8Q<(bjglFs9;lh;lds+={+=!Nhk+_ig-$8`ejY3>Ov!R|u zhKZdZ`cx&(WV3l9fueV%YeRGY<6NTy*>iQ2Uv#f6(r>r9?WaD5C3bmxp zMS{p8GJN5lddLo|CwT$KTxwxLXPFT*QVo@bDLfRPRyftk(&5CMw4|vrq4pv>NWu=A zIoM^P4<(5FMJa8z_WhE_EH1?4IN2y)%G*RAfnj2gOqj?TAcV@9$l^4Od9(~(?*m+S zUrHzyzF;3%ad&f_)|_@jUuu_n=#S&3@UXX^-$<0iVh1Cn{Ao3{Lx5l7REQNCZn)GXK`h^6g)k<2GBj;_KB?v|FDi-@ zMP>$5Tseo~)cPSIPII5NWzCDpIfkIfdb)5y6ZgF~I-YbmrhRfjQD2iZr7dQdL^+fc zNlT&=BaYLh1a{*IRye#hj$r9W*hAdCn@F(6;cbX`JZ>lB^M4EsX5`VR^#3;mXFT8N uN$rgUUuINND(yHOkRqAkbgUbL7ESk|(<0Loy6GEL3*0E4zr~p@>2fjzEJD_h0dR9_Iv| zn(%ga2@c~AgeCeOCTK}-u15R#T(lVdY$1)*e&_Cy#B2M!*Z(z3VK%c(WN`8sv+QTL zZ&^XfPlArn_Zi|YQ#&u~cZF*;agAAWqyziZgXzS|zl{|s9;!E@M#4|TrOk~u>96GO z)~X<*-WFQNiaM84nV(Vz?Uo+Fq{lt4=*z)mI?|WbxM50cHoNH7EtM#tAv=@9F@Qgf z*}oaUFTI#rY0E6JOB1heyVFC${jR=!xL@nq=;&x*czAB0NoJr)rC8J@nqN-;VrOSp z3N;cac?20`esDHW{jr=L?&Gs{XlTeA%rhK5i^Y+r2<+B?>o*e!q}oH5C;zT??o zE~zj7MeZF9*emti?FBk{o^vTFDI2k(N9P+7-?dc?4-f08tE(TI)*OPvPm~ppBn(?C z7vSXD+uOf=ds7{7x3W?W8tmcWaWXBfo%!kI%_zOApR#&lmT$BppGPCWc9rZo3$0iG_|zEB#0iZKkV=C?+U9M);+wF zq7W&YVUNKqxw*OVU++6ozZN%m%Mq@vRFBIoB{qkrojp6sSoskvURju(q}v;^$>jrD zSJ&Qmo{L%fI^KBqF26H%?tOXaJsT07WBwabhPAxBeA;x%db=sk?2=|no(#_K4O2Wf zw-gc_ELR`hX_l|&Xx1%|%gEQqMyuv|X4@v%S5rN6zdN-pjz3J5jc&kf1N+e~hia|R zSy@>+f}GKl7uiL;B0T2i{b_X)RZmC9zFY9h+D}shQ^VoK=2S*l>_DJ>z$KOj?S&lL z81?j!K2;0c=FBwssZ{#=`>TQ3wY82iwZbJ$L*Oh-Hi9Ssj}uy}?{PSYLB85RDmS*Q z3icpBFVE!$9$p%67UJsuW1{DZvzJ$&C~=%;Ga?jXlmUf8ncb%6(ALc3@vN{|sXBE! znAcW>2L|N>z1sTvt*{3ROG_8}1m^6uKoxH))$h$ei;Iira%!?zhcrFj%H7s>=H4+W zXVnz3PKHNBbP1EA0-{&38C4A%8yj{%Ztv5o2Rhw`=$A zbt`Qprn=JRuAPwGSX*maUb^1g+$@UjV0??@*tH+4H9@egOG+Q|cnL6*ErBRo+n+ax z+8+^{5a=eNHceJm_Na^r2z);@l#Jd3y$!ns^?GE4VWX>(Er5pw)oUu0vh%oH?m3A>vcch)9U&0j z<~+4fE68Pd9TCi_ftExObK>zu+4{xB6zTU*gLB5Db#qs8@$<>aNsv)nXdJNNghWcz z(WC5qtxaI;RLc{H>v2%E*VuTqkjvCEd>UbYsr$n&z{nl zx?Py5%92lccoPI7)Vx&*Dt`h4ojfbhI3_U_nH_#v(b3V72i#Lrm(YAvXt+-nF$uB{ zgC~&>57-qwc=s+61FD6ylJg5QLs;N^sMhk4dM#B36(r8D5W(uHA%O-)_i-sLnU0xwFYv#n~o3OlAW)rtCPGDi^YS!=9E1b zYV~$+Mt>Iw1n0B)LEsqe{Q31AMn*!oDjCKurPHn^{Y7oPESphHnq1e2O-oH!pyZu493q> zDJhELqF7cMisOzKHqtdS9LfiZ-P|v`2cKwqdj?EKwIx-ULCN)NR&f=t z0*d+tf;I67mvq4~{pY->s;WxQHz1%(CSze%C_Tf^Qr0982&*iU_>lgRmT=dtl`NBi zFZ9rZH($;Slw&My5{JmZxjD?-xdR_c3s9Yg%QBAUgJn+JS)!Nt+_Ixa34e*LOG zR?j|p<}W0bN#urY#B2AaKdE>o5cnG3IhyHexCI=XP>{ZsH{2-G8|d#>MAMQ|QVzhJ z)j->L!jHv8#cV#&)WV{rn$0F8NA1wPa4(8x1P=`f>5btoLsvLqt2_KIp;auAMB!yt zSpDYgzXCX`U27$c|FD&Buz#SI+;kP*ptpsAOKdV{nkj74bsXgct6G-1#KJT zFO3QuS-bo{1@VdGSZdn;FEG69ZU+_Y{59clB9)#>JVOC4mA;=_u;{B=5X&l``ml?IeYK5)*(hqLzxht79WBjLRA&`bI=;!e(-R>_sY(gK4`G* z<|+nm zPlq5Xx2zUzNpJzjQcW2S-Q51>wHGIYGk4uo3_L)q^1mOY0Y_;FVp36s%jx+3+|CYg zLrl)zbUJRgkFX643^cQ`zPq@ig?E>$NBhC1ju~}1%bR+uxYso)Y4S8(KaaD=8Yj=U z`ZZIYQ8`aUE|!WkZ;&ms(HcTxj{mPRNafp`oEHydQ@yI#?G& zYu;$goK+0?f=%(TCSzVtPfsXR)aE;d zho4{bz_J^jZuxsGSx$=Qd}bXLf6<*Xh6V;S7|dJK)MIuK_Thr4P;e5V=BTTERk*Az zE%q4n>CQdMne`#0UV3V(FfR;NqeaofEk~mCYS<*pL&9qCJM|O$4aFGF!ZL@nvNDF) z0>SUK^CSc~jz&gCnmRf<6CHcKbS6O|PyPMG&RsS?b$6EL5#n;NT-8KPI=nyLLCfS< zRyy1BQ_oYz_3)OImHi-mia@YY#+l&ZgwOr_`7_nOeeo{%N&3QG#qesbXx&?U@$H*vDuuYq+d_f)zv8# zwFP@^7zC~2_0h!t1xr)lZj!sv(;hDh&~BbeTbGiOZfdh%2Q6zj15W;B?=I{FpU9AtliR(Ny&L)zuKrky-kp;I-=xiV&lvb%dvB$0L{C?D zib*r?-5JxcI~-|a!1HN*gp#_GrnKXK>P@mwy0 zgm*=Z$6{%j19d0?bFbK2HV%$*5HnBZ|L@2e^*D)xv6R?f=P`r?F^m(e=8x|$vBqxf#n2kRraroPK} zUd=bX5`a9ty}z!x6Uyw47i&J0l48it&K`6UzDGkt<8EWK=ELzUvwq65CWR1UIQesY zeEd7;;&}V&Aos@DZ+Gbntu_T8s+Rtuw7J1VT}pBFV-e0nVaD z@fwmP!L}7fKET6FV~u$=d?$H;m7Rk_4dV9l@+#8ev?f&?WkJ=h{A=CeHLSHBNVx!i zVZ7~2t_lKL6+) z935ZnF3Gi*_HAx$QE|nNedzg}t{cYXPY-6^*|^nv+pu{23_Em(zqeO3d-(6GmELCM zcP%$aH%JG$AUSQefx9pqmyvTkJe za*WF%mx850mifogu*6Q=wx>7~u9ky92L8dp!MJh)?R%#heRZgb8Bf79#?jtAuE&yA z$U(|K1+{&i)N6JLY4M@;}`1S@4=RhHQk9l_hz3%tOm= ztOC98Uh46co1v8Pjfg2SG6o`LOF22Y%pu#ym|EV`CO$*J?hpDxy~DEg-!=$oCWMYo zp_jL}Wp^Z@B?56@2hlT_@x--iw5O+snI%gmgpp3fg(3!Tr~SlKgP;1+^Bsx2l$6v$ zxo+k8lh9J#axW)uZ|_Ld+EZ-iMFQ6jU3m9b-l88ee$$#rm;ozWpfRd6DV`x$oQ?PHa zf`US24L8o8Nu;pz_cux(W@>HXcE{BCEI_^x>1*nVqq5rm*)a3jnT~^{?d&&l1H6(l zPWaWVT`q%)npyxw*GyYm8$l&kZ(VI|Byx|caP}L>9YTD3d{|vG_4W12Z$XTk^Z&WE zTMRrH@?`n?*?I2tXMmnhHL*B zA9((FVqzi?Itjeo>kDBZ02TK_9CF&4svCqD|3ReAR8*}ku2GxYD+zr7Vl zHZ3MvkP`S5sbgpui@v!&5wVp8cR1dji#k8|4~yO%;K)^v>9O=LW?!Tg3%>Ma3k}HD zu&e4@QN`?X7M^oEXmzpw>;aO-s(Z6L#(co#>E3sYN~+i%OR3zz{Q(^o`JkZoPp)X` zl#~=>vm#Jaeb3g?mXG`m00tNt9`?q;4C_8;n6eNN7M`-UwpPb|hs?;Z?|$zSi^ks5 z{-87qt=fXqu1-!BFxj*QqJ`zZf1T`r@ri%?-?cQeDfB?FkkoD$78F`()c6!05g8eI zV-;HIz<_!4ceZXF>IY%xi4R%)thetbwC^u`ulVD8szku?Uoj9$!B=}z`HoJXytZ@) z`u}CJsgm)Ub;N^|?7{N&-$Lt7tkQ=)tpb_n!1DojWtTx+n*Y(8-l+k*Kv{Ff<-Mnynp|mDDUuKLoz#I(>D=`J=DP{2VOwqHastirVp31QXy@#H@PbIWXeCkBn$GRavxGXHZ*l z%gD%Jbd~fm2K*fWiAY1?rS^Tr{BT@i>8J2$ILqavp4pxCX|3#!#l_*#pWK$^F=PwS zJ2$?0K07ltD_)RaGKZ%35)z>roYAEFyRGwT+qIhb(7hAgi7WeSB;?v7Ec#te&h;7> zuR$$#jea~i=`mQ*K|`%8&ObhX{(RuwdL<3ngOZliy&TFJ+fDeZNR2{Kl%5Dov*4?nyV{CV&zjxQAh zB8AD3ROQV49zx2&3#QuqEX@C2FNG6d-|H%&d1A{NQ{daZ`0Q%E8qaW1WQF7<-Z~QR zzb@Os^h9j)30$VY-QF(!sv0cP7^H}6hXV^Ui+-gpMks3Mgh?QR9RIca7cj^mwB&y{ zs@!=dccE4Ya~n#Bt<-37QV7=WUtC2&O%=N3vT%NXKl-dGt@V5@l`DW`1f;pRN+;6g z6coDiK}O4JTX`d484t7JjAv)z@70%+3tRhg4kG=ocZK*4ak3H{-p>sVa0AC?rs}9C4Bq*qZ zLA~PLN}d~ZY#>bWuDj5icWMJBqJO*UChG8#l?5OKd$}(Zharx2Dv$xE+mcB{#%xq2fkpW zG1K5Lh$OBjv;(|YTwI@nYaOCDT^3$aSt&)xyt(JQiX{BK#f}^*f}$sYyVkNFZ{^A-R{0( zj2trI9z?5#5vfasd;aU>`}@Zj%v^wYp^YO|SjRSw{E-*{*aQ=%;m#W?vXElt>FVkV zE3+hOthmymzP*2}cf8|9N;>b-h{yb)y}jLR*VW!$jL2LNLTMe2#e&r``p;5he5sJ-VDz+kt{q7l>WWvTam>2k(!(!s#_Xr ze^IP)rPMw|^9a`Kc5`(?9=hlzI$tehA&@~|T3R~w=FOYy2a4o2*4Fy0s+%W2*=ZBb zF%B0!Mx@Y@$>l0YGwLz@9=2G^=SoVEG?A~{uO1Dpu37`o86=qqF;m7JVMZ^!>SI7- zcE*Alk0+~>x|Zkt)QsP}X4c89LQn{%O~E$o*GXMm=0Fjem7Og$zt-aXJ~!o4G1BD#(p`uzFx$X4UV%KEeIaHY^N!h~RerazUHISl%0kYL{p+A{4R zFJA1PD(1Nt9wG3pwt>4V%}i509D+-e?Qo{!n_IPV>Hn9&7nqrz876ETGIL9!^6y! zl!;D*e(LUUV(EV95GVYL@QNZpQ$b+z?lwLBxLXN905fEQft0B=9k>HPy8Ci!T+^yj@J9a_X%998-RZawCEUTy)5sS05?r5;=vZ5&hXhm0dMVGD zupoDLcM8b!ba$CnQCsJ*o%cDlRsZlXW7K1b)4=j;OUPG4V>mk=Mv{mb`Z%_LQ97^* z2LiG5tNZ7xrJ`-CXMPm^+lHN7<_n2-eX=zrRk2Ts~$O?Yzeiy#3*emI(Lu)+m zgARh0+*%i|Y{dZILgk>I)KJ9r$O8~4>hW9M$kB02RmucXQB}>@t;@aQ z9^X1DgJwFjI*#rqOkGc!u|F1B;r|WO87^>s`UQv()j)ia8Fy(+qYiO(bd)f7@nYOm zS2r>uB7zSyWHnKFcQsk_5!3<*_Zylj1eY4hnwK&ZR=yOv>bT88h`0xGd z5rDQ1H^zOOWK5n!b zeOW)(zd80%tq&uf9Q(^>%O3pS-ew!bJyP@Q2Npw_LV_XJfqsBTI2UVVm#J%NPV4=9 zoWwdCeA>+=rmobU1;NS5$Swh|B4xpce;9p`EdUaZPOpA_ON;o# z+#FpGq4yFu<-O~}Vwo?NHF$8=Tyd{W7*EzWsWVVW-a|iskL2=I8`MPxN~0&qZ_&IW z^v0mhRtf~X!q4n+pD0meTAB!c&@&}~i3r%}f|K7q80}AdxRPA7e{i6De0Z28B!C~) zoqubD(**+{FJwW@vvt9Dv$Rbc2#w?Y#G6+BnNYP(;M)XmgSv^c@n3WCXpaZ{Zr4vQr<3F=4sEelQ&dN{j>) zUtL`-W38ey`tMf=38_YoB$q~pz@3%8w@faKA|{~iOW}Iqw!f;H7@Y_E(QfpBr3@Hg zo43tvd&ko}`Teu)oyVJr2_1E$@DQf?H&BD9woo_?;a`{C)PSgx4UaHs_1*(n`>K6W z+clWK$HWKce7$gBFi z1&n25Yik-(URhsH0r{$`s*;8NwLzs0O)JE`y1>sJX}l9!T2!Po(-9=$=;ZXRb*DpO zez3}Dr{jW`mX59_=)zm~#fyg!|5;!M56;YEmL{3+9M-hJjhrkKsiBPDBh)Ab6^jRIQgFqaiq3;4<5HOZ~$&?fZL{EeZ@u#8Q zgCbZrXnSx4D0lAlJxvi(Qi-7RdMyOvHAvob{^cQ+M`2}UkFunL4D&kHc8XF{Q>Sqc z{$^%oY7PN2P*zcE?re}b?T7qiVVpq^cXubqokJt*RNuF&3%uih*kgCXM*Wx;unm&M`?J%h;-y2L& z&x_k1^zCth8N!Z>i0MUlYldtuL{yVvmEO>AU@{Y- zjQxhiK#jm+E&?KtuVfA;bW&l~dELp9sN&1-%E1x~XW`&8ZrUB3Z}!**(k!!cD+>#m z4#H;HEgguufY5Ax<{^t=rYCwxPxJ(Vpdr0`{`ooRm<|H?3Q&g;&?OnGH|m!uLk#>Y z@J9%Fmy}CmtNFKRQZ5J{*Yh>f`7#%V)4D(e{nxir1L*!b#F-8BN;0V`;@xA>)0cw& zpFe(F6EXViG!R9(64qhjS+bK5D+{kEFOSQX4$9n~t;1ysK7H_pp;aJ!Wv0gJbhn$h z1|&DIrZd5#)Pzkt+G9dwVWd|Z_jZ7)S2wqP zEgdQW0#=!wl~vy&-6n7@`rNz20#eV&$lxs=H^4I=BiEFyDQRgrXNoN-QQGaT@xr>S zT3lRwD=r~%qYoh7x5f5=K0sah0Nlxd%z-d!)~_eC?%u3^@OwyYw$*1Ry``mvBK^Xxo75!%RW~ zSkt7FaB+QOBZu+_?GXT8P*mLdyYS%PYv1AK>yoY9r7^p#<9lz)%9L~v&hKr`3JN$Y z;htc}h;i&#)*?PbY+W@T7R8KbU%+YWK0GeEj)mqZGc-L{MmD8NX9Ki#AhPIdHCKix zZ(~m`om|<&W=V`juOGa25=Mf$-%-)uUCng4g!?1!sh^)87g5y1D3~x~*9j%BanmK( zs=@_acjk}x&%VS%;w43|omP#~$ZCr3k;}L&EC8DCwbQd*ey8&3(l{&qoaeJGHxJTj_!1$ zmrv|hu0Q=>+a60;Gq_+2z$4vv6lxtS{kvEvK+EeC_wP5estT-71{VKHoxn_DtH5tGY(0uv*&0XTC@OJFxaSVjXoJD z8ZjQcP|!H?!{|#vdZH6RsY1~^J79mvRR)04@qC3S-oK0O7xK!=$`V$81xOVKZ9Ra3 z*eODSclf!X;q}ABXqH%(f!aV+OKbP+u*HAOZpb0_qQb)TZSU5G{QNJ0XZtpHLurC? zol_uI43&DrN=i`21&~7ptg7c4N)D`KiLEUy1z_ikfEh0jS5KHGPL%$QKJZK-*xXz_ zxp){RD?6p@OMt@r`ALLZQ9FVgMF2IX-# z=M(<8a91Wr6L;WU2Te_4u2}gX)p?wiF0_O1(G3Q3N+ogEfkHIcTxauC)c3Tu@U#)P za<>5uB)|_735g3n5q~1e!!IJv&rce$8SsA$oL#N$ZTsekJDcY=7S?{R25fHs0{~K0)PPsXoB#JerMKZM diff --git a/res/drawable-hdpi/ic_mp_full_screen_light.png b/res/drawable-hdpi/ic_mp_full_screen_light.png deleted file mode 100644 index 9c69eb8fd3b99913950f31f2641a0654c812c53e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2031 zcmZ`)dpHyN8~;v7Asb5NvNa+OnYrc&V;a*IQ?3=mrjE&7g=LkP$fc6&af!LrsNbIb?0XB!rE&gwNX4@Sh&}@cP#z7k|&Y{-RZZg(Z7*j@Vl5-Je>MFfogfLtt59`_PwCXqK&yio8>b8@TLP6hvt>AX(ror>=MQlpQ=*Xx15S|7l51xdy%2*3UO zLPnx3oPGcP+T+H?ZpEQ84+A4B)cyQu2f=6|KUot zD7cvF&QjH9z42>3w?6%GuxLMQH1OF`;+xj+QYk3xMFYBkyAXsQ)TP5f&+#*B#NNo8@h!-E*zA;Qa4i9&%Mr6fQ?4^>Yq?jggiJrgubXpFVy1StL5Q(+PGyF3xjc`l|+rT^7A%>$FABWCE1vZTs@z z+}vCqi&U$p;OL>x^7QgDv9S2wu8qsoa2;E@Z)j}n2gax&A)Gsf$>iN%NA7uXq$13U z2DM}sO+gR%4tdt(94~LL#=ro(6L@;gz;x!86|qn#9QJOI#!Qy{dS0Xhe%;1+zz!;iurS=+E%ufStyjuP+FC_5Bf5ZK+*MMd_0=Xz zNyDm3ocL~t+Q^dpPW*wNk zn0^lG%j_Q;^L0%DV19l+h2q1!E0;ph_78watf#b#NrL3Z+0U)a^#8B9&nngWR!O5J7AXu^Iy0Rdblx z%&tES%W6x1Yz(ZgF1uhCg`5Eq z=>mgX{BmFiW_Jp_-k)>H9y@nKlTn3V|EFgwux~rblL9awAnfh!B_c_5I(;IoT+raJ z!iXFCb~D~1Iy(9g^0xzr6&X!@1akbF%{>tU&}g)#eeNR(@$qpoCRSGY!6l$c-03ga zhji!-G`ZnxM0;D-ThNf-w&ON7Ie#?c$vr$vYP^N)!{;ZkI~{Kx(nZ#J8%hCUiA^oo z&86uq9dU2cmWdnO2D{83+v+9*r8N4qgcYSZGi%80{Dm|*yuL^zqJl)p(}8_%LB(4k zy#=leGf}vD@YLgfQ-ATQU*kt=+#Z;IZZ;U#7r8MyKEArtld3dwOpwT?_9hL(&3uJ6XD#P^+P>-qn2i9j&OprnU@wKnLv@n45lbED@(pbUIUVy1-IK# zMXGh}_rT$BTi=IfY7hUBVwQa0sac2S6%FY^cH6VDz`23VcK3MAot~b~pe1;P&sMtl z54?QSl|5wz5L}_D=@rCcTFpKYQrYJYMcfI8xM84XYl;k;A#HXu+zB>79COmAB$H=* zX$t`Xt|_UhBk$h_et%x>XxAT%?y3LW5)|^@)z7b}qWO}krR6dqAtB*KWU(CJl^jmj zJ62NJ-rl|px4E#$oVj?IOEXtDSI~B@9$xV8h+l$FEDN${8-2~sSo}ut-DO|bs{w(6 z8W7<&PIuXL^c7Kibft9!mkMR1j@6D3&?5$!w})m1j~h^AG0)MwmaufbwXcuQHlu++ zcI@F|1oH1-^pT__PHnSisnvK-%Y2R70m;M?`EI|IUAd-qni3;lQFK{JveWj*IC;ke zlVd2>K~WS505r9F7ib7} z@vY1Vr0Bu}`<&XP%Z>hyF`R!Cgg zn9^-W$ort=<)Tge3*Dh~@<2(FkZQ{9%f-ft$>)*E5I+xPr6KG;*c&P`X^Q;Op5!AP zJ{~yQ?7^mK+RMDY>l_RJe7G7yfIO(pXb5oo{-LEMK4jvJctk+Jcb&k#%AKQy zU&{S+@(Uz-PMa7*3EGb(jjpY>vwM4|W_i4#gQwg+m7h7Oukg{~`xyQ-voP#^A~t1o z^x}PN%D}+DtALzm)$$o+sSNBtaPw6C8{736*pz5}0i)-lpWpFA-X7`Wf}I}^uoiOO z?Y=eN#JBQV)V%jH7z{j#Y&VU6L`kQdSZs4H`4ZI_IbRJyrZf3cE=4`5RJ{x)6T4SR z3YLyFQ@THO3D-cwrW|+3cQnZ6Hbir`dV6~#txNVIC5(lUWR49@nR~FvK5r}S+f%vCj1lGpDX0|SHlm`x3Rh2LTfpIoXLc)(;b9k#Z%+^W&LC$en=lB8c=U9oUI zN)=JK_ZDJIf}8X5oHe-Aa{%%~S66c^uO(qmJ(Vj95(p*4*L${#w(J#o%R8(*gIzihSitE#=gnm_Xk*pMHC4e~65HWiv4bYw7@SE>sKJ8Dm*ikz*Vc;+V@v1(0X~JksyzwbQ z#R4%tc=rhYu0$ddRVU-LJfdbQVh^P!BBdv_Wjn&65dPLB&=i>ds}+V4zs0W4v^YzH z!~xnl91c+x4wE*4dY4Zksw`l+GA4`5-`@Mp&(Fs>D9cufqn>}EBh*5G_)cFN2YY+L zkBz0TV{g2ejwPl#Zk*cM+7-a7ugX12@pRbaEKyk&O_@o77H#b-QL|O@sr?!eTPp)!% zI#ySypT|SqEDW<&LUsi^O;s10mJfhs(V&`v{v549cvT;MIz)GZzc7NB<#M@_qBncm z_Ky5FAuuO}s4R4+tsuH-^l9t(ZPv)b(!#>Rs4;S};4B@X9bz4R!QIWxCn6#ut#39j zFevD*LCi)-by?YLu%@Qw*#4udCb>47#cFAZ$;l#*Y*pCPE1d)L>-`%W^?o1o4Vny} z@Z<`gm;SNFg}rp{im!Uw$$N332j`<9=c|!TdNpR$H#{uA7OkJZzP=v(tf=Vt%9Czj z=ccVI$L4L6X0~lZE=oYkd4@Wi(FHNa9p3qS?4b#NGiD{S(O`Xhb0xU$^1yP0uKapV zr>}T?rgb5;me9Adn3^gsY0U0?tTO^iNdK5sX0iXd?%k^!EGQ~nTIm!T{UO&VP9O(_ z#>D8?Y=?vVS=QJxcIn2BVDs5Cl><=W&#|$waY2EBfdG=_R=IpccB~=dWT>wE8DQ4J z!a_h-wPyV`7Pn|Dc>VhI_CSr_t`Mi@%FqioI!`@D(OS(`Wgi5ojl+G>!r{sxj+W4p zgf91tnTCGf8WbF6wYIh+oWwy`z-H7)&GJs)9uxYfG`#C=^1Y5~^m+6{@>2Bh z>qDB5KO{(zs7t`-L?l{UTf6I4>o|*D=IXa%SrJFFv3Y-eEN*F{Ee+vP>Z)UO^e`Bi zr0>`NZ{SL>kH7ELUk`W;IKKr4|k1|%Oc%$NbBSkfJR9T^xl_H+M9WCijWi-X*q z7}lMWc?skwBzpw;GB8xBF)%c=FfjZA3N^f7U???UV0e|lz+eS5K)hhiu0R{01Y44~ zy9>jA5L~c#`DCC7XMsm#F_88EW4Dvpb_@*6*F0SuLn02poqpCkIbGz~{O^yC9b0hl z&_3rp6zd_qv$?1QKUYgil(XJgUx*zCRi)w9+I;iuh;%eLV|Wh?>0Y}W4!dr{f!ZCVzaYf&kqg`uF1&oU~I^oWiG{d*nmYa zvAVk2K{GX&&-Lg*MJ2DLL9^e!eS4mZjfIJ^<#1w7PL7Atp_3<1PM_$pWKsu%(xna# zc6Rpr!h)JheUwg#XkK!8V$nBw?wmPq&MohBUmWPNIMCDO#;sdiDVuNFAA0z~TZyUv z_~RZ!HQm!iPkk?U$;|1P;G<>>j8l!JDapx;ZOzTabMo^3wHDtqFgAXi)Z-Z!7RJVK zXj|^b2a9&fPV-u-!cbRJqceNftT(N#tpV}z_s<8NdhWHdCv%n@!<_WZH=Pov3c0!P zG=v2QPmYXXzEVa%P5pi*KcduUk8h3fi^Alf} z*Ux=%TkTJ|@!v(6BllhVxB! z2XQkoa42qRY;4Ro@%^o&|LEEon=XZOJ0Hy9RtoC58@s(f$a$uZ+TP8Zh0k{H{OngH z;^@R-Xl3Fm6bbLAFSE8@1KDP*WSp;$e+E2VLR5Xd({>HdJ?~U!@Gv!bqq7E zUcH*WYn^;)S=qkWsHk72``1hf+HU3I+H;4!Q(@^7hB*!j2}~B(OaHFez5DmuTeoiY z2rTl^e9EAZ7b(Vg;OWz+>Z1-TwF@lCF)`^V0(UP?;pl)IbT zx=j&4=RW*e70*y{P+~RPgCvF-0*BsOoHO2j`|qZ0+rEh=pP4EzF4rR96gll{xQ*QX z$wBjOY@7R`!e(2dMEIf_yZNUdf9&}Cw>H`6kA;^ff^)1HU#IVm6RtIr7}3C h diff --git a/res/drawable-mdpi/ic_camera_front_light.png b/res/drawable-mdpi/ic_camera_front_light.png deleted file mode 100644 index a537897b668a5283d166c6951d654c71baed1725..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 843 zcmeAS@N?(olHy`uVBq!ia0vp^5+KaM1|%Pp+x`GjEa{HEjtmSN`?>!lvI6;x#X;^) z4C~IxyaaL-l0AZa85pY67#JE_7#My5g&JNkFq9fFFuY1&V6d9Oz#v{QXIG#NP=YPV z+ueoXKL{?^yL>WGgtNdSvKUBvfU(=jY&!-9CTUL>#}JR>Tc_^L3o#UMJAXHpN#$Z1 zW1zwWjTbK}g;uOs@rgx*BTykQsA0il#T6CYR$L1h7jh_eIQ-l{`^_P_E?M^H@4n_q z*`3^EJ8$NBW4YS`0fD zrx!U^uLInsuI0YNX13_=$vL6D`((33+qW!xw~GDM(g|~e?bF+TyV}ZUGneY$iaODE z(&|sTj*>^F1?(h!<%{)JYhV0+SZ!*RwqqT? z*`bGVF}wb*sJ^=VV@Q24|Km*gwzDhWIZi6)o|;&ux3biG-zR_j>Aah?JpaFAe<1n6 zK<1nD6QjRgy7Lat*%BKnpZZh(`L4$OYImf!Ur#fi08B5cC9V-ADTyViR>?)FK#IZ0 zz|d6Jz);t~BE-!lvI6;x#X;^) z4C~IxyaaL-l0AZa85pY67#JE_7#My5g&JNkFq9fFFuY1&V6d9Oz#v{QXIG#NP=YPV z+ueoXKL{?^yL>WGgtNdSvKUBvfU(=jY&!-9CK*o`#}JR>OQ-Jj7YP(OTA!aj(S^BF zkm;C&X;R|iPQ!&Qvl5h94L|PRuyNz1Q3muGO0u%GxXAmv)6aM-yt=rX^BnKi=U+lqBAzt z9llwbAZWobX_g050mq+n>@q)9A9PPF?@>|O`p@peb-p#;zD#=<jd>-a)-CtIDa2R*$e;fVatx0TXcUN6u>C2N zIk;8kY*6E>6ZVXs&9*VbFz#dWZ}@(|{eeyEl73I^YY)~weRtvMM%7H8OTM1gX4edV zAN*!pQrh)fHFIGMbHDQD^$lVhn0z95Q_A=%c=oW=1$pe^{+qRdEr#Wt)W-=+O}&21 zyQw?T_ z(g)HLq#}x{Ux*%@J&FC^>i3hxRC{mSIz4Hg*Dd{6{{Nzl^*K#Xf48|_QrrFJT;K_# zIMt%x#Xp$0$*$zTet%W^ny*5hPyUEn2kt)KXni0z*elepHs#70^#evXugahDPh;vS zCzm0Xkxq!^40 z3{7+mOmz*-LJSS9Obo4z&2-L1;Fyx1l&avFo0y&&l$w}QS$HxPl&Tp#UHx3vIVCg!0Hr-jpa1{> diff --git a/res/drawable-mdpi/ic_camera_rear_light.png b/res/drawable-mdpi/ic_camera_rear_light.png deleted file mode 100644 index 4da3328f8ab3800efb584297ab0f842500cb24f6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 726 zcmeAS@N?(olHy`uVBq!ia0vp^5+KaM1|%Pp+x`GjEa{HEjtmSN`?>!lvI6;x#X;^) z4C~IxyaaL-l0AZa85pY67#JE_7#My5g&JNkFq9fFFuY1&V6d9Oz#v{QXIG#NP=YPV z+ueoXKL{?^yL>WGgtNdSvKUBvfU(=jY&)Rw%RF5iLp+YZow_?uI8dN%|JG*a!W7jK z4n;1#ey5Yn0Usqe0$sd1MO5TipT=HV;^inQsOK%y{a3^2xYV`X-;SB?H?ewL{A|9Z zakULIqiCtjYw4}u#C+G(JLNl_PcJQ)W*4>~EYTqF{N&3$_Z20NKC{`eBJ`tm6z@?H z3!c49vo7psINvz^K;|3MMCXEKUldad7Q}7KGY_AC)bbVg*JY{2l6QI~Lvq?|e{0{l z$mYJ(Mfd?rg-{XGo$RSAgsqwLqV+yXRfzujTk_26(*y=q-VL+A_}=8#3Y`Bo;a;p! zLn42|Yo2X~{xd|gemEC*tzdUtU|xZ9(@cxzC|BN?txW4CH?4l~EiHj%4)gnl^9SN? z{TJQ;dF|;a_H?;zOPT&JyE4l~IPdpw(+?sQ+%k;63-@{0Wj#oHx#iUl`(<(QE8AcB z+zl_k>TYw2_~qK#(0~u0>w!_HTH+c}l9E`GYL#4+3Zxi}3=B^u^%m>gTe~DWM4fG*=$N diff --git a/res/drawable-mdpi/ic_checkbox_outline_light.png b/res/drawable-mdpi/ic_checkbox_outline_light.png deleted file mode 100644 index 4c3c7225845e63fa37c114ee836b7c1e6befe9f4..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 776 zcmeAS@N?(olHy`uVBq!ia0vp^5+KaM1|%Pp+x`GjEa{HEjtmSN`?>!lvI6;x#X;^) z4C~IxyaaL-l0AZa85pY67#JE_7#My5g&JNkFq9fFFuY1&V6d9Oz#v{QXIG#NP=YPV z+ueoXKL{?^yL>WGgtNdSvKUBvfU(=jY&)RwH#}V&Lp+YJoobsW;waF%|J__ijwA2n z7>tbM8?7Bue9lP9D6()kHn^PN+#xKesp7#B_xW||cflW9m#58m8GCw4$R(fhz28@@ zuGdPO9AYQ@JDT0=@dl9&#!Zf^8ugOJc^qHtinZRrg4I2><6q9G81Mg zlpJJZ;O7?)Lz2waQU{lr>D>)u}OX^YgO(}ED64J>2b21 z`8LCU0nfi3`yUy!T+GPx0N0PYc`mQswyFeqrJk31z+b?!hhOy#<0CG=x2Gq#&iZi8 zuwP~E+`pS=)cPho+_;Q|f6vMZ=8eV69l9jnI^Ii-)4aG{;X~A=#||+k`c>x6{8KqY zzkSiJCizKelitlt(0!R0ad6?1`}3*=kBLvRyZL-6Q?Rzi>_1mrq&>gCo$VMI73#Go zn}5-XQ+7Jud_ROXZ+Ea3khIpl>TI+p!}&IY@dMF08-nxGO3D+9QW+dm@{>{(JaZG%Q-e|y VQz{Ejrh-x=gQu&X%Q~loCIGk^E*Ss- diff --git a/res/drawable-mdpi/ic_image_light.png b/res/drawable-mdpi/ic_image_light.png deleted file mode 100644 index 3bd919e33c019e0ded676cecc488b419457e5b7d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 697 zcmeAS@N?(olHy`uVBq!ia0vp^5+KaM1|%Pp+x`GjEa{HEjtmSN`?>!lvI6;x#X;^) z4C~IxyaaL-l0AZa85pY67#JE_7#My5g&JNkFq9fFFuY1&V6d9Oz#v{QXIG#NP=YPV z+ueoXKL{?^yL>WGgtNdSvKUBvfU(=jY&)Rwot`d^As)x~PBZjob`)sczc)eEw{sGz2+R<;<9*J9XB;7)VuUTc^7W^+EaCooG1OEgZF|ppvE?B;tXU>OpYy*oSZ#Q*TI9LKF2mEp$9NT` zo*HN_;!G0unzAvAiFLQGPmr2p)XV7;=QHU_t5+U0Jo>>)^v^oR{sV>^IBfzxyzMG` zFm)cocE*#|1qqME3i7h|*34{fF3V+Kn*8nDvvAd|x$iDKdzB_VX_u%6Jwz-swA9JIzU7V|b_eJExE qr(&J%wnD{jv*0;-`?2YEgLGr{^5Le{^SFT z7I|w4=9G8}n=c6nYKibTpqrzUBf>n1W7@4>{1GmzH8>mpWF(6(T5@keM2EsZHtl-F zMvo(BAHCZr*m2zTm!J8WS=xPE+q2^+UgLn(5=tnR~-~%BIOOZ`s&#ZHkvwi@}%NipK+&MylJB~S8NxW`SzR7=Ah^MB|fVccxEk&{>s?9aDBzB&o6juUNh^Mv zvsY|;UP|_XbDovIPVIkjGWNfGh4lN|cfN0rdU2dBiaCs-j&1n|9l6yTwl}>m5Pkk} z^_0!h?81+8z8$+KmBYO4opZJ5bnWkIY>}qd?rrvEc4a*(c$_N<_Mn+bKX1WIERt5&T>bYM~H00)|WTsW()(}&56_gnz uK{f>Er*4?a diff --git a/res/drawable-mdpi/ic_mp_camera_small_light.png b/res/drawable-mdpi/ic_mp_camera_small_light.png deleted file mode 100644 index fe1dc7ef50a0cb694287b6e335db6929b5f36c8e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1305 zcmeAS@N?(olHy`uVBq!ia0vp^5+KaM1|%Pp+x`GjEa{HEjtmSN`?>!lvI6;x#X;^) z4C~IxyaaL-l0AZa85pY67#JE_7#My5g&JNkFq9fFFuY1&V6d9Oz#v{QXIG#NP=YPV z+ueoXKL{?^yL>WGgtNdSvKUBvfU(=jY&!-9=9iu>jv*e$mxlOyB!`RqJ9qZHcaqV9 zxeg}HZp(JfZR2V!jLG25OWSs7W|LCk<5N!qN~Yf2^&%}nfxGvD-l~8JNvh5)Tcr*L z7^ONXIde};*c9yXsM_z-yO&DuZ)OO;J>SUu{%-a8@^?SamFpce&{Sn`2n{XT86#Fa zcV^()uu`QYMV1&n@qfJ&IWPU;Z-}02e7*C9--Fc-!h53Db_K0eIe-3q?ccwDr8eWgT9Zd?%$;nX=>;Af8)h}&x zAM-idQ%@Z`bEc=btW50p@87xW&!1T~`Qd^bGw-WeQ~Ue+YA#>CY;QUD+f&56qjN&^U8-08_H^GcvL2P^6>IjuDbe4|Jd2=KjoKa z&RjaBeCN3{HHC$O^78WE-@JQgcFJpM(D{5ubLQ7)IhRf`O`E2>=iHe&UQ72SrA070 zT;AfJw_@qca5W{xr7f#g$uk~!S#qmYplPP6smfKE;|v9rm6h@gEDTp(mtI_#&!92a zPrmK&!^-Wq|DJhUX3yPz`18w>Ul)4vRWpTyC3Y<!8owJZXP7#hJI-BXqur9AI`0Y!h*n zWcrX$TKaY6vP}6|=eJE-xiz-9=T$`D6#wPRmetLfGsnip+Pa#(!OGfN-cqJdpCN+x z)~&9ix9-TKUCW<#>P)GU5MTRa&+_u`lb4mwotf>Da`3C?waybsNn8vbDniF+ExX!r z!jD&Y8Mk5%+X>&Tkv+B-pSZY``CiWIba^Dfu*TcldzyzzQOWyzd-K(RpjF`7wVu1V zE-bOvUw^Fl(+-q-^07ka$e9;!--dcEowPe_btzN8KjB~TWtp3Ao~yBofBNLflSx`r zxvZ?L0<@;;R99C|tFiO{zr1PRK07%{$&0mi^VhA?`p2S}t-pE6o=xJw+^<^V8c~vx zSdwa$T$Bo=7>o=IO>_-Rb&ZTd49%?!fXGxAL>dI#_|uA_AvZrIGp!Q02E|qvOP~fx ukPX54X(i=}MX3yqDfvmM3ZA)%>8U}fi7AzZCsRR16oaR$pUXO@geCxxY%#3> diff --git a/res/drawable-mdpi/ic_mp_capture_stop_large_light.png b/res/drawable-mdpi/ic_mp_capture_stop_large_light.png deleted file mode 100644 index a9705c1b9cd31c072016a8a9e804b4bf9e57fd56..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5095 zcmZ{Ic|25a^#8?JLKud`lxad_8%r5GWesHz#=aXN#+EHc_ANeR&6*_=l0CbuNwQ_h zRVvDoznhK* ze7|aIvj#2@Cly^40H{i2_-jKA?kT+VHSPd!zrg=75q6sTx&RQs4*-O405}FogkJ#Q zg93ncYXFeV0Dwy#*-aREkU?Xst)U8>|9ib`ElCC`7d$kvUf`>Ay#Cr!BQ_$TU6MwrR3y!_9TOXYgv zQzmZ;JCw&5IiRrpD1B_F*JtV+mMy-Sw8Ors9lk>G7GLF^6}{Qq<{HInAh~g zA&__U#&={cyLIAt-}0nxUzYL7g&X&?34M#9_e;9o;}@s8W7uBw+uO{-ZL@mU9Svz3yZD2npg+3wI_F*%E}n9SS$jmN$kGxj6j-c5R%PdvaFO|X=pzsB{M4?{y>I(KpC@y zYsft3W=3!T1>C%E4ya-a7&VFZOc8gizLEBu5*3eM3o0rq3dACByU%~tB0^MHV7(@) z=L(^Nj&bj8(QwBFkBrH-kn_`nvF!V+`yOalBX#8lN;Qlo2F3?x%E|$W+wJGUrYZLo z;Y^aG=0V5iW`zm?dYD=$uaha8J+>p7d!B?IX5aAuR*J zy;eSBllgTs|4T|@31Rmi*&uJZxw`%x=weoyRr24pSLEkcw~2>(EWc}X8|AV|?CWA> zWkmy??(W;`JBxkil?b{;LrNVCY+`P1?)aNi-qZbq_Vd;cuD2UDyk(;VVkrQ99i8|V zkA*vYVq%G-qoXJ^I=Z)4-FJ6MOO%sw7l7p4za$iE`>8NdG4r%f=?s5X?J$t0n~lbE z_2G+%tGrrGU%!91LfdqP-vqMz7W2cy!^NTPf;Px!z-Kl$c2Rk37?ivs=QFB zkFRgN>vU}?XGbMD;MDK=BCBN}sV+kqfzTwvE{7S5nT@nZb=mL_RKI&X*Tk8-%Jv5o z1KQ_noel6#PEK*MOMFNoyIMYIvpQACm<#B5ROjg(=79ekPV$v&2ssbloNJaI-yl!C zN!E>y{w`l52HMR8&+AYWt5=2ZV%# z#!5gkh2QSs&SIYG%;Lex17lDxEj2a2q9P&T#rw$(H3tViDuVM^anJj?EmI%)2$))a z$mxdFIjyy4aBy&E4BHJfvU6oH3xmb_Yg}-7U0&V|?!dx<{|QOY%gY0-Js&TB{i&IfO-09=N+Su5@2^VOY;C|B6icwuv+9U_X%po6)fdN(fcV-OCAKBRx zcj;U^ZCO)$Vto85Bf_gI@}l~nk5PrG&jUJ_;XhF5?Z>k?ND|iA_@HVfrdrs**m#l) z`Ai@-2*IMPt2;=hZX-{ay)&&SZ*JxuA0JPIgnbBhbHP=aw9%mK)<$2cBW(ec`+Q5_ zJ(IBF^yn!fpLRUpXd;|edCM&9gLR0H5BWNnBA|uMiiY{7`j>7q^)}!srbn3W-o3l+ zVrG**ux!#6bmRrZhbj}O2v(=-#s?AaXmr~jCE|~Emxmh7MQNdes;X~J5y;1&=2X4g z%uqzRd85Rg!=?UYeWws5qJ*|Jp}S|j@&^T>jdh_mbn|29pE)GH2nLJ zB!DII?hJ^ve;$jjG;iGf^r^qUf2zsHCA_w4;q|Eb^3R?)U7*_PDfyuS^qREvL9kaV zWn*I_0$Fa+Y{ZN>`Nz*B-DGBgHu!89@!q&$^)}AI!5YT!yQQoXQyon)6Zcf*2`1tJdVPI5XFL~hHEh#A}X8if%$7o^9>FKFlZQAZN^kGoj#OKeS zmqthRO)4g!l=#SjO`66G*nIA)J->u5D^h#b(WC+8>FMcLxw+-20bZ@nqrKIv7)=W0k$I2ns=8>M znG?z%?)kVtC*Rar?mKnK(V8+LXMeF;L=a?ERT=E=?v8SEalNjtCgKGJ1v8XF6fON0 zBBK5TJmmtS3x3J_{r*9%Yl4PfO&vW+z{!=Bm5G652+(Sox8KPiCNC9o7F1MJq^xI_ zTwPNm$!SMZ*Av-x_>!XLWN*E-fv|GyaeRge5*?=oG`; zSQ(_f*VcFmWc26yzYAF$Fg0Z53dO}B-cJ<5!i;a>zRaN3nH9%7NU6O z-LKl+QB#Z97k|AA*fG+CUE6Xhx@;#)l7wmjyg+$t>uK;FCz~(_14_c456OC6RpaNb zF)^*S^zjmf+e*>q=H|(uosJ%f4~SnxtDIQC)Q+5rS}oo6%(x&bD9T}~m#?(b&m<0V zCqL8-DGnHp2BgwP(}94ddP`kX4$f3kAOQUH0A+^&9VG~ZwxturuPG_XXGGVhYjiNbnf<&oT&E z{Qo9yeb{SB%eWgaUQ|*dHhebu{rh)F2NmITk>{N7k|_X*bO1|A!UM&lrI0=O#P06y zSrM~(9WGAJU$YJFJT)f5fBr!hy`8nO5*5{WagN829vufA|5;;>F5rwuN^rSjz#pfh ztv#>`bgFh2>%8zBdz&k6NAqc5;2Rhg90*sjF)=ZX0AfI*?PROD^6MD4!IAoG)C-;B zY!C~Gl6wiC9rw2D^afG{4b(|1oY<|al)qtwSu=qLrDP5Eq_~6xHD6y}-lg%;(cM51 zvYnsb`vH52W#x;J0096NdlSY2UN?Y$^{r6at9}vUkznyk=d|Nt`48;Nkhq>_IdV@d zjr~16CBV8ufiO2Tl=?b5E5EtpH)YbPqYAV{>IuL^MMW(s2-lGuJUk(d$UeND`z=4fnImpFgTutD(VPrGkhXk$ zd?Vb)RuHhy=;`Yh_+CUAta&pB`0r;QdQq58hMbZoDG9YJD=T3m zmuye=Ndq7rx&y{7r=Rp*7k9h1w$|g}!-rE1?p8*BBJ8}p_EZ4pn>^+V(<2}I`$q!I z+HHGcd)6a3p-`EeFI|8i($*?yr)SFjc$?B@oQa0;L^da?FtW%F#Eo$jzha`7mzK<} zOhB(k(*?KZ(I)9xTDI{W2TwcJoa%a&Oi!C~&_}Q^(rgPZSfir#1j0c|abw-1OaBEZ zS%9L{%Fn)Uug3iPBvL-Yw%qdzyNA|M{W>y&@3ltBi#v}NnIP}n zzyIZz82r{KJ3Bj^a46%oBD}S+(abWp2!ajwJ9EqEocnEcb*be2{dxUo>j~C1--c&q z;wC1HR=j2X-44)FQhyx1nGeWhiwsGpFSfwWKSNNP^{=IN2KBfRBwQf?w!FM74~#vd zW=I3&oXreH@TA`#!e}L@XJ(*u5I+p;sHsUdTOp7mCnu*tt-hv)4TQ&P78W^aa-9uz zb@?E-YlD3Cd3|AJWu?nPXT$(S2AGaVg&K+T(`FR#J{vG+5XctLLzIM~7m`k*Ztm{q z*G%&TVowzm6vR{%*h{g2K~J7Y2Oi8l!5A4uf@KD?R$f-tCAZxYHrM>5u(>%43UzOo zxnA-%H#aw36|h)WlSZi-a6)RJ=F;l8Y+&@8eR$2=4;);TRRshEp*sA@ z{Wz&F&Nv*p;7eL{fq(=QgJ5~xU?Los-}JXgD;ZhIAKqp2wy-d4d~#AUs*8dg36mm{ z3CG6)6C)$}uO#u_?mqDR^>w~a4Y<;OKH*l-* z*Fb<<>Bv2MYP~!=)o?INXlZF^Ea_>&ZWLB8^C6L-?sTJNz=?hbZ#i+BvoVO&5>E-khIw|5lu(6{;NQ!}%f z&C`>E5@m()*4fXbc0$f~xq%tOoI6F1vCl4CqBkYtJ zSKbEEwm7mgS0Qjq*yf^xg9D&Wx&G?atK$o7{29O!2q^cN2L}gBIv3t)8mnMnLrP+ zZL6pt(Kl~#MbnYVWT>%4jiauPtE;O7VXe%lB7ihiWtTVQT2Ddpds3F<@WS=>Oh=Vn zpCY#I(YOmx+}GR7Ux}^ZSLde8ymG)O<>{NiI}U@vx;^0O5X6|hgo1&IiHY~G;rx)y z!4*l+E#RmgJLy7)O%nt^fcs(B+j%mefD!`(LmU`hNkBs8x%N!~f$01JrwY+kL{p;d+{ z*V|6o+Hs^T_qHk-_;YP|pA1ZGhyTWnsDsUK*WX&UnnIK_bFB#ree>~tB=#y^-a9>T_naaX z71O?^wBP_f%i{A^Gx4^!@ph24^K<|gKmsK$C4#ywf|4*4za=XrDJzKLB!Sbx*+0~Id+`vwDdNAD*#o(_PDy}PXgLd(s@$wA-2#@-*_@9^(| P8=$3jU-j)B>u3K1M=ggj diff --git a/res/drawable-mdpi/ic_mp_full_screen_light.png b/res/drawable-mdpi/ic_mp_full_screen_light.png deleted file mode 100644 index 5462c598c8dfb233dcb1cdff1daa0bc70645912c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1280 zcmZ`(ZB){C6#ki6BuPS+)^VaH8Keyill^0+hzK@v5r!?T5YSN)C>XOs@nuWT0yE>3 z3axTvWw|iZ#4vZV7jmZAX;$;4>?jtonbC@BFx=iD#%-iog`B>8wR z^#%a=Fz8g4n*(M5o8!(;2Bb2cM;5^vzP$v zT@HY<41o7;NjV8XJ|2JNo6DLd!)ox{;guX@UX8M1i znRmqk;Mvch#lPvO*P1^1D`r_i^zVt!U;GX%{VI56d>3Y`ZkjF(tG?nj*RIAmU zr`06q3luWCu9vLDBMGX=#XpYDCG5R0XzeS9QM*0jq;w!rC_a1r_D=rrKCHg}2&M5s zC_thc45wM%tmIp{+I@Ym1A#B4EPxlHg1-r?_EcJpIRyod=T2uKS0d@RlYfDA@eUT3 zlW!iY>KQNmO3(6d6&*PD;GChkd0GVNoLz0NqB=4Vx5GzXlT(swMu&zzV(NNpEfxz( zx$w+$jI@q&~Yg| z^i)qz&j*{$){)mf1p3!7tGz^aC>We!<2ZWO*)*KSd7h19{v*!JED7MjViOV)NF-9f z&(9G(P%FfOvHa{=;5+}WVBoLxda`+KQE4~0x?l(B@=nBYoj|?zQ8w`dd#i8CTgVC7m?3URj2@OLX zLQLoe>cdBm96yxPu^8){_kUg3Y;E@)8>H30N(ko6K|UQD8(SaJEw=6XP+;0t6jF92 zB(yognhmnV2u9W^hsDM>uzmh$U80nO7Gmq8oI@*HzB`3N^9>iq1+wHBSc`L&VJ% XumoIwL9SUa^N9c>aRar9vZLf*6(1ix diff --git a/res/drawable-mdpi/ic_mp_video_large_light.png b/res/drawable-mdpi/ic_mp_video_large_light.png deleted file mode 100644 index fa8e3e2f36bc158f60df6d0d690b6445ee74e7df..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1451 zcmZ`(do{&88I?qSe47f5JHU0 z4%R4Ja%-B^lyzC#N?|1|6V+sp%N+aDU%P+o_nr5B&iB0MInO!od%l{gdH-G~G zRNY+h1PC2ALs<#R-<%`QLZA@h=MD{2&nHh+AJCexe`18h~EzY zlLf#kL@{Roh(Q1#Apw9b06;h5&LeMosGxN2mQ2uT-xxHzIx&32kJ;PPO_cz)tEF)!@F^2tV7mf<0UjZNzGG!>pE zR8>n8Qv7vvqY}Mgn#t)JH@i1vYjVbc{LI?Ax|Mq}b{CA9WVAp)peCdkm{*Q?a_ERM z&gcoRpQUk4Rro65^Q4&3qeAs7EGz_IHrn9fUnKwAIU5P4hO}(I0{wwd*-jG$lt9GG z6!qNk@R9 z>;14Hg982CyLVUf`3`VJX3WR2wg-?Kad~O!%xB)NN$($<>)&4ppJvasr^xNxEjZW= zYn{xgAlr(889N${_9CM^l#QQAjiJ?{Gvb~O!%(>z6V)e7SW2Al+$6l4gIeBtk5eD#+WxX+xvT^i;HfnNDWR{pRM80rYQK!oOQqJMxy| z>?N;W={d&BX3R?amUFAl>qb3+!_Nx8HNWgpYV-W{>(>ieSy}4P$b_}E*c;pP69-Pw z7KzFx$TF`PpVGeAHwVaf6G93>=i7+KW22+;eVQjH5F8r!UKB?ORM*A_dhg2QDryej z=1_{qu9NkKFaEjLG?g}THor1D5W#VGC@m@q_#@_OY5KDR(x$xLj}exZmiM__?wj1) zT$3M$R9m0EAgt<@*VS2mk3=?M9314LuCC;gok=x09{$HrC<;=)t-U>w9vAoJjZ`Xq zD~HaAfo^5HEN^kt5OaKy&$qfnCX?wT(zx9qnqO8{)&Na1g)6}O%KVz=Di>tS%kRa( zIa`xEm#fa7si>%U=gAY4=Y?r=#x5VWb)Q9QD9;E40&ze~xLgCCx~0a~LJ^cdYr5!- zx_UGhiolE{>DVAmSfZZC z0>OeEGajUOzr)0SFj`$*?d&Pwa%+GW%F%ISWwbSRTa*oQZGS3vU~H^@OB_mzz5dgC zY*Cf@@xe)-&?jgQsLi9r1qEMJm?>QGZ1fiJIPhRSvEv2_kzP_#vJl?V3TFm*=UoeF zEyy;>e=+6C8(b_dE~Yc=ODqP_w1Z1qfu>ZLroIuk{zXdHJs6nqVKE+>vZ_5}Nd@%x z_cNiEfli5}wyC+9B@#I^Ho_mqFIwEr&ySpXt}U6L=YvlMObZa+YN+`TcJ@$Ktd?`)FUZ^Xcedax?`S6h(mmAQ9GRD}=2T z0_kgQgGHmTD2x^25Eg;ZWJwtR5zy$t=c!lzUqBirbVCCDO$Q%3H9D3YMFCF1^m7zr cHySyFLZFa?uSAL|n@bb8IeX&kkC3kY1H?m}egFUf diff --git a/res/drawable-mdpi/ic_mp_video_small_light.png b/res/drawable-mdpi/ic_mp_video_small_light.png deleted file mode 100644 index 9204827a04584c4ddd37216e5f58ea14a6be6546..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 925 zcmeAS@N?(olHy`uVBq!ia0vp^5+KaM1|%Pp+x`GjEa{HEjtmSN`?>!lvI6;x#X;^) z4C~IxyaaL-l0AZa85pY67#JE_7#My5g&JNkFq9fFFuY1&V6d9Oz#v{QXIG#NP=YPV z+ueoXKL{?^yL>WGgtNdSvKUBvfU(=jY&!-9rd&@K#}JR>Zzu2dj!qOgR{wtX_qfR& zM;0BOv{tmGq=4tr!dR}Rb7fPVtTL=iLzjG!F9^Cb=MOWBv1j|FElo~G6`hq!HM#C( z3rZD#E`48ZHN(NXMQv;J?*o$e_~*4hw|st}{tw%$2M-wTf1f#TUSH1ol= znH>sB8*ko>{Pyje(7yNff`U8_n{Up^I3CpgdUsqj!x8D;V~72ASzB9&XiYU;8X{G( zo-u1{RQr`o?k`b%4twLw-`;bb_k1lMFRv|slR}8sQ7iuXYgf%;u8TIDK7D#?h}Kqy zgtev(*S0IZ^I7ozcTr8v9xEH0Jq#w+g%{KB#RhmCWmvXsSy89UBRvI&H_z?23%yW~ z>%V_FO4(fJYObUZIW=!9?}e}3dRw(%^42;7 zE2~>;!dAEb{P}ZwdU|?1!v|03!^6Vjo)qnLZwPFdH)F<$oV>h!vXYXO7qYe< ziWBp9IU3=0 z@Gu|f;)srp-W8#=Ubpja*1eu|GUd&`e|1~-?8&)p zct0)fpX(onh=4MU4WA}I1*SmN64!{5l*E!$tK_0oAjM#0U}&OiV5)0m6k=#@WdKB` zx**aZ;KrX;6b-rgDVb@NxHTxYx>y1=NP=t#&QB{TPb^Aha7@WhN>%X8O-xS>N=;0u TEIgSC$|ek+u6{1-oD!MNS%G}U;vjb? zhIQv;UIIA^$sR$z3=CCj3=9n|3=F@3LJcn%7)pVryh>nTu$sZZAYL$MSD+10f-TA0 z-G$*l2rk&Wd@@jkv%n*=7)X17vD?XPJD_#4o-U3d8Ta1K^zD{$l{j8+I$7s+T6s-) zfQwC6`wW+e4-XFNKREtDNlsZq$xUQY{1I=ylc6UR*$;NlXs!qmbTpW4y4yO@X!GTI z{;wgcmKoiSy?uFZ?+o^?b?e?$#jToGb&o5{At-~nI&Q+3xl1~CwRb+;H{oK6s)xYb z$t9;dLTybYdy5@ALT9>9+WcHpxnO|X4rd5;8nSKVo6@Cx%eXQ}FB@!-O*^f#90 zB#v25pLOC|dZwwd=n8i?wqs`^A837;y><10+23*}oPEhJBy2UQMBT9b_oR9J%g+7d z{r70ilDTK%Juc;XJ`esK*ZjY)_wMok8_F0SBtMW$nAPL^aoV%V7E5Q#zHnjKlNVkw zxBmDszuP-D+kMOJ7Uj>AXPP5XqUvBVC;35J@7a!PS!v1U4YfkqUpzP-+-WkaVZEbP zmA7U;ca7xol0}YlpDj3YwK>_=>O(_^{Qgt>l0!Mg)yT`HYi& zHu#mLiZV7ya`x=+v0riffb@aY74Nz4G5zKH!}EtBhW(#UK}qtYq7|K?d@6f0?r&4A z)y{k`vGVb}vp3CT{L?;nCokN_^v1i&>(bJmjXRgDT;gmyV}tyy?UiQFCoNgK?O;r_ zs1$QNgS?8CiHh8lR09Xa9kinbj4YB9$yvqI;9=i&cu4P8gIobMZ? z8XDy2I&3)2u5-S0cV6bZ8VB*6#X%da@{g_z*ziiKPp9FkW3a}=cOk9%{B?YLe0h|M zYQBH){8Jd7&s4)-)26?bebyCG1}WPGV%^&wKIQr7q*ODTNuTlkW9@?1M|jq8?meo-@PX?tqN_gas-0CODixej=Pf&vdEU~W0?)tiZTzWd*t!1jWUful;`{E!PoDmw^20Y=BH$) zRpQprvZ^;7s6i5BLvVgtNqJ&XDuZK6ep0G}XKrG8YEWuoN@d~6R8X^JML>>SD diff --git a/res/drawable-xhdpi/ic_camera_light.png b/res/drawable-xhdpi/ic_camera_light.png deleted file mode 100644 index 3b74ad655ecfb5ad4c4597abc2d44f52573b1173..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1481 zcmZ`(dpOg382`>}Hs%s)S*zShWG+qSI*l2RTze>T$=oUtBFn?p=8#aWB)KN1LM9Tr z?3`bx$W)ecX-MlPGP90~F4mdz-}&P_@AJN&=l#6T=l%TmIUcZss;-Jt1puhiyeUBn zB&;+FshFWk-wG6pc*wm`qfzFBpa<4Cy0)DMj9skN11%poY-j%LE?|<7%u-{c-S3#qY zLlP(FD*OuZp1Ke7TsF?CsCls|gyU#c!`>4rWK3aEV7rPYBVZ80H`o58^~8$e>?P>& z{ahFAPKGlGxkjjZ^P&wHi^QkbxL=IczPKu(3SVvKcLl$!5Y5a;tI^$>MLb?3V&;cBv8M{qL!^vyt!4SP(1OfHjgtg5c@&Q!5iUDMp9FUlOq4Dv`NJAZ)NQAA6P3(ZcUb<(e#30O zvL)qwxl5!q4FA(rOq(@BK-M>krsTn^x)&-Dk+2%%RF3mo{rY5&XO~Xz>OPLy9yg+U z(ZR%TNhF&~g+(lcvxLkObNoPe-_1#_7PkZqobIjac%=(8ZiCIX`KVZNa1%>tUdYqa z7~kuS2K<5dkV z+z~9wCs?(SiU%r#^)8z028bqI*UP6)!kw7Q9Q`SK^Z{ieB^I6fAoKP0Irka&&>qgu zYmb$(K-9YPLy!xIKwN{b@#}M7Vo?cit;Mc7e`hw`mS~&4yPV!y;Tity8&^XkY_Y9j z^YD{fL>h^w_4C>4k>PzF(p{7A$WFPT%u=TH5;}7s-f8fMI^@J*RscK5?(9NN*(C{i zHjDX#rQ)ZBni}f(l}MTQh1Q6fd^5-wN+G6PT^r~H7Qfg*SRxT7X>-d8Snd-FbW`_m zjSaiU7LDZ17R>$AYh}%zJ;~58GDpi_m^=oR3@(*B%A2=9jo#s*7{UGgZf9KO`ZAEP zx@;9RcXUsOyucv)28Ubw&_beD5+ zOZlfhSk9`xKxs*f@HNFH`{}qqZ*HcEt@6O z?3UZMO|s(EvXqADVo;-mN63{&bc#+gcrN`>@g{>~NZCP+)^ReD?nf>hTqd1O<944#6q$8CfxDaez!u*&C-%OO8p1 Y3yO=OXCD&8twbuIdG4U_Jfe^N0~&vTGXMYp diff --git a/res/drawable-xhdpi/ic_camera_rear_light.png b/res/drawable-xhdpi/ic_camera_rear_light.png deleted file mode 100644 index 92727afbe86379408737fb3fa8d0b1034eafb465..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1091 zcmeAS@N?(olHy`uVBq!ia0vp^1|ZDA1|-9oezpTCmUKs7M+SzC{oH>NS%G}U;vjb? zhIQv;UIIA^$sR$z3=CCj3=9n|3=F@3LJcn%7)pVryh>nTu$sZZAYL$MSD+10f-TA0 z-G$*l2rk&Wd@@jkv%n*=7)X17vD?XPI|c@3VNVywkc@k8XW8b97z(t>-=E0MIeoH{ z09QOKE5{0smlFi`Gpl4-c}-a0pwKAtL-!}E$%Lj^Y>fg!tRfYO(PAzV|yH z@83HAR{ELmcSRrAY+#f~_EGJ=p_|1xx1(~V)d#^HtT7CFOyv#dt){uYc**l{cDiNP zhnJlvsucd^cI*+KXsOP!rf;*-r|;D1B<=0$ z5OQ7YgInrtE%CoSeP2u8b^J8cZA?6==B&l^?dcr0yj5!!7dcO4$eWO^@_UoK6qBAD zQ}KZV6L}kgt)_`+a&9>h{*CLyESuhW7t5kkr3Uq_h~`^Ms1)%=^3 zSB5RxqVa9RroP+HpD0@MF|Au1ROQ!KD7e96QfAGH$1)ilCM+bBXzu*6#IxnY#jv4)k1otf=@TQbWRR z;h|~OC%-e!2z9S@+4ZY(y-O2^Et3yJ_JPy~;*BW+(K9z|oBQr?$iB!{HuZ=|fq(g; z=NFSxcTY6?_upPMsodd2a7=pvhi~@&<-g(sS_0hUZVUf#&X|1Si*)q_$p;n>r&mn- zBQPs)XX(DRElyQ2O7C#5QQ<|d}6 Y2BjvZR2H601!ZOiPgg&ebxsLQ0KW*issI20 diff --git a/res/drawable-xhdpi/ic_checkbox_outline_light.png b/res/drawable-xhdpi/ic_checkbox_outline_light.png deleted file mode 100644 index 64f0a2bc79ec98bd3735a9adb893455f084a3233..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1199 zcmeAS@N?(olHy`uVBq!ia0vp^1|ZDA1|-9oezpTCmUKs7M+SzC{oH>NS%G}U;vjb? zhIQv;UIIA^$sR$z3=CCj3=9n|3=F@3LJcn%7)pVryh>nTu$sZZAYL$MSD+10f-TA0 z-G$*l2rk&Wd@@jkv%n*=7)X17vD?XPI|c^kdQTU}kc@k8XP(cy6C!e~{`$;I23chr zUM{Ov;cC)}a&T(W`^8o($`KUgbZF7~i>dMpq*daWI!%LCwYjW0G5_Dpnf*uSXwUz6 zXJhU0vO`Mlo7NrwHs^Egear7R_O(gx-smUx%FipS^zN)>0!))Q5^KLN4n1%-lkp70 zQ;WS`(MkfL(?xh}4lJAa^0FSAVuIL=)&Dp@2!F8MaMjsn=6mi3^Lh=hJ!(};39jrF zs@FR6u=auZgU5DLRz(=i&O3KByJ<=0-55{HuDCMZKh^8%_>Sx}Hx*KJ6o}AUIOCk} zgXm(TMLyNxpH)NTVpz(3dp_5C8*?ewlV?Y7PL0OPE06YV|9Ih?u5Xjz+gz4CuML^+9d8T%$NE7q z?EkwO?*6Zm62G&p6#cdThr0e;`PP2xXV>I6*$NvHA2WL%7t2nXt@76L!%4l8r7T8M z|KFU*^WU7w^3-e;r&W%VZ`tsf&k6ux9_M^_~avTmRo(P_y;?bxrv@ z*Jixj#=^z^!`<_K$g5rJo^4l^Rh@EwJ%iu9H@}nwm}PG4tXHXx4cxi>qQzf3xj4k0jWui2RNI%qMI-;cBkd$MZf5X1@JCi_yL_S?$r}$19hdSE*Hexg_`O zbB-I~Y)YLGesK?#Q%h#w`B5v#p{4$^@AQ!?fAep-{d}$T;Qg_AM~*O_-*q@nl2O{W zGfQpLl>K5%oI+Q9JTB!)Kiw^GqH<}>rX6w4KdT}b_A`~g`EECL`bWLj3p8K5C^#zV z&3<#_@|4q)_cr%;m2TtO;F0w3X+6h>y@DSYaz(b)*r`;U&|bLXh`5y6qf2#nzjjJY z_-L!texPO9)Q#`NYW=UN1uVWM_L+6rKCx{f zpTvsveXDiPmR)Ckq`pk{f#CzLsV0ueaZHXj&cn7Z;s9R%l`Bq!|H!~gnWQ`Qnkc2 zq9i4;B-JXpC>2OC7#SFv=o*;m8XAWf8dw=yTA5nv8kk!d7*waU97fTQo1c=IR*72! zm!(ZTP=h4MhT#0PlJdl&R0hYC{G?O`&)mfH)S%SFl*+=Bsi1;`!PC{xWt~$(695NA B3`YO} diff --git a/res/drawable-xhdpi/ic_image_light.png b/res/drawable-xhdpi/ic_image_light.png deleted file mode 100644 index b2aecfb587ac748ba3546621c512eb62b807a21d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1131 zcmeAS@N?(olHy`uVBq!ia0vp^1|ZDA1|-9oezpTCmUKs7M+SzC{oH>NS%G}U;vjb? zhIQv;UIIA^$sR$z3=CCj3=9n|3=F@3LJcn%7)pVryh>nTu$sZZAYL$MSD+10f-TA0 z-G$*l2rk&Wd@@jkv%n*=7)X17vD?XPI|c@3Yfl%)kc@k8XP(XyaTICW|88D~kif;c z4^G9_uufOBYgor3?{Jj6X;+Yo3rpcswRbTqG+vx)s9+RynV^uTvP+;;l1ER;!^i0I z{%*UeD@(G{s`Sdz5B&-F;!_%XuKrcXSIgxIErP$CuDohhS)}06vp!NL?{3-D&514xvU)f|dKan(EX#lVK)$_J{&J{*#Eb1qeT0kZd}Vhq)Exi(xxLka z>5h7F|N6;m-EQ%BItmEPHJy1Y=6=KHZ@=|EobmYjCZD&-;a+o&s{!jd25z+ySqpcu z6+1R;3}M>mJD=^@o+|m3oHo2?Dh{b15Pu-$8~jRsQr0({oQoH{x;0kjozLvP$LATf z?bnrirf+^mUh)$UjQs5pq?Fmdq@g@=JmEob$cF)7;@i95?B^; zpk>a>7_(rHJuVI^ne&&dzm^tuX-B@*&Ydy)T`uW)$}=y!a;;^T>I1gA$Elnjx|EGo zGvh8DjH%7Av*$L-EEZ9_(95}W+mY@Ag$Yx8-O~@hies95Ao}ueE#HZlyWC4LQxfZ?dMlz4lrc&l98g`+?{!epgNlUR$Pj6}Q4>JGFoR zIVmbq;r9W{N4bGVq)RIo${$co*t4Rn$|0qziqVF#?}AUxS09CKjo+E%HT0dIhi!aY zbN2}I?KL@CCDIqZJl$R1>D1^yp`kqPIqN#3VDUM-?`m#wDh>|q75O3jOW<0RN!yz> z|6QlWmA}iM`9apY-00`0)`hwS2OE#y+Mi?<@JWl~2dB+~%A+g__f;i-dGA|We)iR5 ziMH-{f^IK_|1-!xnfIdg#j{9Yeo`%QjVMV;EJ?LWE=mPb3`PcqCb|Zux`t*UhK5!q zhE~Srx(4P}1_qZM6+=)o$&giIbF|x&z?RdIJ#j?bfcPJCL<5^!Ue-~4LtK#|VZ6v<#{4%NUWQtUrz){% z6fTaxP<$ps)qY1>SLvAcoDMxNE=$F^N0{;s41S%_8Q0sNl8i_`ogpeK_s{L!eNaQB zfw&-Pr7gjghNIEt&pGG>W5hP~>%L3yitfF4t_4*{SSIwhB{Oq&Uj2OwciX;zZLwEJrP1Z?JzI$Z4YF zP)-O9+7D;Y;K+h}GR2&_(j;vg`Kh!1Fd&kOQ}mSLDUsbQYELp5`p>ZJc-pIrw4QC6 zp`5ecX7)|7P*Tdzb3X_b&YDjNGx5Sxff$8VH+y<+$6n9v2{W@7Zq-)eLu=w!Ahtn8 z6P#_Djo@O)eRfgmkR>j4?d$WNTW@XNAB~kFv}jEBy?&mBqg z-&LJ<73i5}$3dYb(J-7HG|b{!dCAX-H#^|3^BOQ&g#D%rJB}5%5d(kSRdvmgs@wW) zPr|>4ob&WB&DFbsdWsH5vQLqz&;k)1ByFZB*Ljm5r0~Uwsrx8pOyr&KyRU;K)0=nR zCoV>>27OwW$7XnLFZM6}F5u)TlH@F!peMVN&`r#b56v2`lpZ_-T0>xE(*?Ei+?GdV z$3ef>$p^YW(4K#_@vViQiw>9ydBe!Hp+dmgjT2Xp4D}} zE{qYtMNAFCr;bjVtM&WLES8&mbV#2PV9wRV9O(`IbIt^=q|H|K)mmb<1wTV3`k6}x ze_%DHX)owAx>p!<4IFD;&5?2q8)4@jl;3lt_6=wq=sN5^D^hVMF6SC-!bx&4O;6Vc z3ksl|!S9mhh-{-rT$H7{yjMW^z$K1bJ5TzYl$FH8UV(MJ4!oi?JCJu)qB!%f0ucLP zhWUADm~kRAn(*f@#U;cIWsWynn&~2tJ^D|U1}^g$wTJ?(@*u{3{X-b?_LAIQ(Yrj# zFBQkn;XM=HV}^zNMI8*krD}%I_6W2-W)>vJ@Q#JFr@ujt_}$c4i!x05Ecj5JU4Mp9 zVV6|5d@Rpse%`0hT8#@Is%-SX*dtx5`F5K9DDHt<`Du`XO5|)6e06FhX5{_)si9(Y zK7Vu<$=F}3z?t>_kPs_8bNY!QQcja+^HPu<=da_QSt`{o*)CgrH3wV3mU-=R=N^{) z_k^xjK(a5f8ake(+Uk`Yn`U@ATy0r$Wlp;Lgl8mW&yNf59F*E>OIXM-sdK=D&4AX} z<(LkXo!;fmNTEy*H>41bWEF$L9GnMKbXyKO+5JS-TnH2>S zH!i8BkG@nA#t5A(j&_W6fMw>dG~?+$!ULv>I*xUaIRomkZ8|E-2hIH zuNA3Ii?>hK<3aNrXs@Elk`y?8wG+lq#SQc3D7RFNoiZj|zYx71r^aH4E9*)Bq#h06 zF9@)0o!Xu*7fZ=X7=B1!lA=gzB;3@LX zsfFsS9b+<{;P%vZN@fLpaB`ZS=qm9_?IzfD9@*3FbL~F!uFZN!nf&8gK`xM_ywuuy z{zY%$2H%ghU#Geu>q*@Lt}u;isr10(XAPI##z8$!6cy%2$1mGqUnn0Nb2xsd^I7;v z#W`=#+6G3tk{J`F(4d!J7)+vW-h0|_gU=pT@kg14FYd^J`XL}DDf$}(bDTD}n^2#` zU!WgB#&%7&>X*T2+hPBEJ>`)_w%z=pl(FHyvOZd!Uh_-XTI2N^B*QS!A%8MQwp6$i zc2XM`Y+dxP&D&*y4O{n+Ai6%R4pZ13Z*Aa)w`KhFw)?2&@tZoJsUr26{eyXw_7^+F z%B2%!?->>77lpS7h{THtKpGjF8XB1y8k^vZ4_P3Q7RF|VM&=eqMuvLCZ@(=NBLYK$ zF8%)npZMV|ZQQeU!6PClD%LL&51;}g{P7xCqF*rH9q$)->EcWLmb?O3`{U?`w!X># E0ZUDW_W%F@ diff --git a/res/drawable-xhdpi/ic_mp_camera_small_light.png b/res/drawable-xhdpi/ic_mp_camera_small_light.png deleted file mode 100644 index 75094e1190ff06dfef30e156cd0525bdbf4827ec..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2690 zcmZ`*c|6o>7k`G4eT$oMbI~N+keOb}9`48}WNElrGSe7B7$e!Tr9vj_wT-2kGTp|K z+Jm-8r=RBWtp6ByC=X{=%;$UwrDfX)v06@|P zi*bSwwaX%VAn7W=Pl5pMYhh;rKy|wK7C{KoVWCdemZ0*L(k!H?60uHp07R(+5Puzj z4agKf13(xQWeyL3Q9b|)=c%9M3qF_rOiy{iCjo zy*gioCDAlknS=4?5<08ix#3;K9~}$y6&sZLhtu17d}Xvb zdaa{5Njl@N0#=QRNe};<&!{PnXUzQ={faHUZWo))SLj0c`}>V%G^o8=I6o^tA2r>BvI#Nq|Rmz3VsIET5dp^HLsdiD{BB*FAFNCS6nHhGlCd z*VxDi#6MxNeB@+XcD6T9Xk_Gnwt7 z{iaF38DFVdTnrMHCE)S+Cdp>A)GQ`*EG#_SRRF)#vKf@i@I%V>C5d6KE1Zvj0S8CN z5aWbm<8T2GJ(c5HlPsce#ABFdtKOj=L1c-@IA$el{(Aeo33o?s$CbVcTV1a6GvP7 zJ)4TB$`U20XeV4)eQoW^!1mA0$ARx_tQsSh>1ZRPsBF)*ueJRz!1Yk>tH8l9)BKy< z;9nAxlS50MzBW!}_~o^3%$2QzVa}NoI=;TXwfkoZX|(HGTUYtT;iHjinb<8o;cr*= zt~~mvw#=y}b~AIe&mkk(=V31XXI)%ga5$W+9EM$=RMKRN9%nialkZCc66bQ4qCcl- z&;%2Ju8vMlMapn*FSQeq{V9ld5WBE)Rcd^}vY;|yM50tot>M!mj*7O{l+OYvQBdjG zq%M=$X_#lr((bSk0qCysnQHfKE*&&%nXKA%h(<+4Xi~W8+RjN zhu6r+NV?iy(RV>w6+N@u1LZ9(W(wvLot>S2wIYXnM`jGU9z(0%?(QQ5kUkc-^D_XZ z9pL8|*}KFG#~?FxCzsb9Oq5k_VUS)MKey9zb91|aw$|apZsOz1EEcPyu~LKYdDc{% zvl|iIQ1{7|g$1-{Yn)LQcBs2N`_>w!{d2l7*zL0CoqCRe=TO|%#{6S!E@`tc%Cfo12^U zOG``RXf#^mg}P#?gnx$OpL~OE+x|90H!bfK)Q!b9)igG4$PjHUnYU6IZaD){(^RzI z>fF1);^N}6_K9!Qe(wI+`FUefU2&RsO>zMN(X+U?$dO9=B5oLd|;f838r6FPuriHRh2$Sp^B2xa_TQK6GV;+uVp zIp7WQwO?B4>+6?-y-X%^g1m0MsU-dj48#)N~ z+o6>VbZij32BLjBZg60ULJUZBV3F)C(oMq5Q@n$)YQ4u11EsWbAr1UgJzeuqs)Fu5 zfYq=ss%J9&!K0b}UxmtuI9ymwT^&yh80zYtIV(>p*gYuVygZUuR^QOj&^bX)X0zFo zW_KQ|gvV_k0#I(;S(=?y3^!1^HZ-pj(%@mWX1MPb0Avf9PAGHDO8sa|O|g->;4$m! zKeam%a(xv)>_?9yh;$}1Gf`N2yJO*sIF0cPdc=~WLyjMWA5jY%zJLE7^?0bMclNTn z8UIW6J>F>aQZmEOh-j;)b-1-5gOZ3=_op$!!@}6B%iSto6rFMdWkl%)F2q;*! z+#~yDW@bhhh`E;kS-sbMVWK06XJ>D}v8WeBp}Y;Bd3}!!{PT3ZwngIGGcz-#eF6?n zvkU(749dTln3!nrB_}7RLZFI8r_&#mm6ffu-w@wXR#8z=gxD}{d#iy570AWN$Vj>P zC(>r?<4dqFyw%kObV7fWAVovKDe7sXA%%)|ad(dZs$eod1Pk7o?U4vgX_MRw{;sMbzg+y(v@^3B z6@4=)sek=yOGxXOicq|;a)j!`^78U8_mY&|@(K%^Y~-!-`sNiA???+=0Uq*Jt`-&+ z#9g+tyX}uSi2xYKvv7;&tlI_i&))aJkDhQJQ*$wrabl-A%fKHl#FcsatiVRwFIly1 zyrmO^j{B{p4a917XBfTi#Xc97gdOQNY~`wbI=@`qU0=&vNzux zI-UDP?#)$(d7~v2ttmK~t{};hgfkDqi%ODz<3lYf(|rN30qh zWZ2~nF0`{{(gqew1z%Gr6w6Gk_eq)S^$qzApK1k&wn$_*-72*RN(Flh8Shgu2bwix zQLWh_uNssk_Po2O$2{7lhdf355_DQp%kwMGh<7+f`@oixXvZ4W3rQ}NTSP|~r+EFf z1M9=&VUvHqPlMX5Lzk`oyzEW5Ofn);NDzRdD72mqN?+&b(X;5|Mkr|N>+DLD!|>LP z|8WQm_V)9M{QnLIZeR={2gThToP&KXhZ86yVBsBnfuw2^Nbn^&kqF+AAw8tsUJ%$= L*<&g#@z?$ZKOpmd diff --git a/res/drawable-xhdpi/ic_mp_capture_stop_large_light.png b/res/drawable-xhdpi/ic_mp_capture_stop_large_light.png deleted file mode 100644 index 62125b3f3c21be78d87d56bdef15db9048ff03ac..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 11858 zcmZ`_6dS0 zoxe1wNq_^`X3w6=LpQhovYPTgf<1SfmGs=eR_VX5V)tt)2;zC5EHA6+J+%|y6bM~a$WYW?_g*#)mH@8KVBFW0d7QlR$`3~Z;s!MmSx^fV~uhm@o<9i-3DA+ zTU#hNC+ERS_?^5&Ek1N8aTw`YxKZ7mTToCCORICQZkJ-d*yIs4310Zo_%rykXQ{ot z&(USj8Mgu*8k0~L{v0|3^?QQ$r(L#+!f`S?$c2{BVDIWX%<$-zs zHG$Ng5r)eV)=#SdlQ}0bz~r; zMy98hn8hBg6(TbqP&RQfXFZUS9<=x?iofONYVY3hS@FS-B7cYF-8LN=Vob=UeDUhy z6gp?@rd??0cK@9qiAt>;ddj_JWczx1osv-F}PB~R})d% z>)y4)5^2AaZON_ik}dq;2@7$zRRXX1Ry4(uFAhB;7<8g>?S@{c@a1I-|Le0gi<|4K z8KH?+BW4{2wT?SMtG%gbfh{+ZddW28D-TqUzH+2I*x914E6uSk#>~dHREpR|?=;*mv0Q66fk8GqnpanQL2Hl=@6g}< zPKKL;?Cb@n4@zIZ(+Myi+okB{kyqrfS8yZMpQfD9?A%Q$fOb~zN6Gh4Q&V4S>*{(7 zLZO_}ho>z!hI>i+Jr|+vU_d^6_z;qx;Tyd@+muEl9VqdPBfN8|oPv^ajszFelRY}8 zmc3k3rOuM<-UkeFM=Q@<`XG-H3AV~;qeLZ@o6$D8(2FO5*8K~5fO>7&`WM#_+bO1@=ms# ztg92vMLEV->X&F+lkp-@yA+shGD-%=?ul$riFLo7h+OvI_2Jq;S$|iTcf30F<=oAc zacN`YtH}nB;);L^Pi9uuoQ8&mXC8Aa7IXfGeI|Ozsp4L|V4$CWAeNIlU(dZ0igVY< zPB_g;jr@Cow38ZV?718~N809tI28f**5+n^rgTvA!N2{bya*yX&S`|#Uj63grc6LU zK%_b~HZdCG12N5!;fdi@=r4pR7umMrdS8-0-gf&>dAG=n^ON82nVtyTyLay+%F!#K z+d^O?T23n!*Vceb-y%Qp0Wt12J13{U%TD#g^+$F1M49nG%jE$QjC0Q}3W3l(HI%Cv zvXO^TVKRkD+`f8RB0(%HtYNOQ#^Jq_X{VbYPecGkI}AN%JX^+^2`b28Pc;2Ru^8HB zk_86uPbWi=|7B|kM!r$t*~$pCIRv*svf#e4hf!I`60;8+CYn<9RB&79z0F~lCsQDe zl2;O?wR-pgw>Y7!#TzQEcU!ZXac(CJcp6JBWHH|w@!@ zXBZz3PdY=CeE;T@%;TE!(b~4`m+YGA>aN@2lt#MZ{{Q}BLEoUdJFOlB?aRIr84IfU zZdn~>-H)gUINmTa?@R10i1wGL7W}!xBrF$67_8zq0DG5ismIpo;+U}}2~N3B@Rls5 zBVoZ^4Cl5z=mxz`wa=;N??O9evbvYX@eWu*XpB>Dtt>4+oX+~i=O-=>1YNr^luaOH^WVkz)}G(_u)1nB z*Pkw4%l)eEFF*7V?Gm^3m>U_V^!z#P^1ipgJ4JI+NCct@7KKXNj z_?wpMBjsq$G_k*#<2yfo`1DCM(sGgnD%Dx})abnMv(s~TrarH}esUt?l3&sX&XETq zW_t`@^PdRyXs@||l!2a}2m0}bBhy+KF&%H%GQ82DA7&kRR-lQ9ZwIWv9X2O zHDE(la*^q4KJjfPN*-(UA$w3?N>i-a5BwW`#6cz-1Sg>|-m-^NHjLn3o`S>>vyp(nVq<8;jq z%4O*{4sHphR%is*|-|O$s-v6%nP7{n(f@w^&byYKXV5Q~5 z&5djz#D4O5dWRlwULU&m?Rn}!+HKpc-)`dCvj_F1rMphIU89+3HhrK9Fw%piKIais za9itNL3_V~Ovwk)?fS{uPu|bO*@?qbQ&XGL*Zc7U7@sibee*{i_d3?*V>pw@EfVhx zx+h@;X}}i5v&N#!35r@OB?Vjhf7Fs^`v)T{`8kG@nP?DQI6C2#uC8qlBD=4jzAS4VMKGajVd=*@5N@+IRt2I<+!!NDI_>J$MWeLmsf;21%B z=Lwb(^a}$}=CckZ{sLr2o0t5u*or zgZ$8-eI6EuX+PU|zrMb{cYCsu5PAnDa@nh%954?@f>>BsRM21mV`lR~IPEZO|C{gi z4`FsbbarP?Z;yfO@#5Np1HY`aG!QG;1)#6DGc&KO0Q6ZOE7qBS&$a{w9n6Q`rG{YO z^mwD_^@mU6{GoYa=&^RO5ZzGyMsZ$#K)uZ1=C}hz3=5qMQE(>RV^@p0JX}6r-as;_ z(tSg{Js1t6LD5(-w|wPF@^t1Q#3=5y+h~|%c@(#fLYyw=40;0BAb<^iuN5(3~044t#ig_Egc;~ zhT~HTcQiOTBo{O{H*Y2zIM&n1sFoQu4U^D$oe-n&ayHwGHrdv04%7PuvZNO9*w26Dw_oaw2{%I7b>uG;yoJH$aG`L^si-kL*FNgpS#T=dgd03J{~A zr0nD|sIhkzrIjl7611~S;{SUUqi2n25-!v~Dt;S-Gw+lbGqS z`H(S2QO0wMm^?~k#S>RIx0~GTY^D?2R4^%6l<^vG+oPETog(J;DPqEV)5T8_`1`+U zoEQNDC7=}@?5g-ay>+~+A((uuUBVvS=zF}04z(wI{5XR4i=W^yAmct_hOwx5QS*w> zpvl)G#8E|JYzqZim{a|hbe@+n_w^OKjczRlyHQb5-g`}_f47Wp^r6<>$;!3eKc>ma zXWbv!PBEzNF)(CWKBA_p!}E+Z%AS9xnEOylN(#UA(?_-!yYWK2DmQ>))YCv0x6Qpz zQC=+vYV3Z6d{458!QG@ji6ANmp<(}73hz~Jo5{Kght|>VW74QF3LbsUmztWIW&){= zQkets>+1T^^#106yL@`iiWw?93O*_s5@(&<$eMRnkFEeZp7lH34cMEhcVm*nMODA1 z)FNA%w*370GaL^O4>O{@ZFjcmVplgMrzz;>>Qn@sYGL1d`2O3W8Lzv|EMR`zVRG5u z-`{`~0bB4hx3^YVGCSQxd5O~!)nsMKD8b?9o+_)udCvf-it1eAM|YkW8`~K|+;jMl zHLzBk`p9`qCAEvHP@mD?fX0ND&+q%2EIo3ABQ z+_cIK>$FX}LnP z)0;db!#L=U@|aLD*Q+T5Sp2Bh&sdvS4%fPNnvNTJ(zM&K^$iO>wqd0f@<{ppUDM zEH2X<#!rh&OCQk)q@CwmS(~nfq~5g6*W=K8n~*`*^=lbR+9mp9j=nn$GXNOeE1N*F zva%*zUS9HVo=`jFQnQ5pSa24t0+=YMf_r+fSf}hIGbss2Ui$cjr&v1Ap6#PIBqOnu zwl7MNgm8{n`3Gho8p&}Zg&WUS(-0tDyfi$M(~`2t9r~1*nJF#I&;P-@$;k8UV8wWD z9jv1$*VUMUo9fJk#A~_Ybp=N zYtAdUX;Ot=kMBMq7}-ZYXP-VsA7cy@O7N>(-`x3F-7d@9z)&dxV4E35TGX668oQDfZ*vL8mU z1kQ(sf5*}J`N61;I(xpdMY$w0L>gk@Ww61Sx5R-cNaLHE= zmp!jSA4vwW^YKYnIn6cWee5Pnw$#&XKCbGUc-j(p^+_d7Sb1`C5~NxW;hca-$9fQi zx54Mj?ExUOY`Iq?*mY`IlYS(&%Gx~l47PTkYf zLzQfKCc`){v-OC3nvfD21b+jr%*VyGmEaQ&_|0Q+aRW2Gn%(8@j}b@yHkZ03f!4a>T29d3fU`JN;9{S5CTe<+Dh3!f9OTnC^vTz14kCQVq z=Tc0mCO6zGFt7=1J}Poge$B~wla~l6{>e_K%a^PMyqtQ1mO114z^n6*!fCM?kKGiv zi?Ti3+yv2D3tV0_&=t|XlpUXtBm&`YrfNc&?f$%~Fqy zoSZ~X=?ltn@9ry!WlkO*$?l5p1?=!J;^7UtO8Tld#5r9Rw5EF6w}%R6UkhSZPVZcS z7!A}j6ElN9!WJ<(N@NPA7e^cB<@zP5Km~uSOVQl>)={?cS8N6X7kQCb>)Pkwh>(Duf*ObVF!IuXmQ_r>hJVwD9X_qVq{}uqjRvH zV22)wh#0eTaUC*IzbuVd-wi>R5eqo4Dyy&8i-{=oDu1P_s!9zN?H4i$@3LV81BHgQ zh~%Q`f(*J03_3nLTnmNBGS(0lU1Om&FON&VNC!!+pI#J)Xa_>Urc?=3hVK+Lr3?mF zL#Mg~DPk0+HHN>bom^Z|KKP(CxZpepG)mOUwX9qMbHVxD^Q&uoeq%1kq*@;pd4dvS zHBckNhWOL^h>o?>G+Y4v#|U<{rP+6tu7LR3iu6zkGe zzbFK$W5-WO?_h@#LO;Pd@PyGPo+2{Ima*GPjKntY&OW0%{CjttOD5BPq zU8ZGNn z-|F7euH|Cl5E;nD#RXz4_J4($Bu#qp8>feYx)Z;aLM>Mq26L zRbZ^W!phQvbzw61=QW$3qnMS={GxD&Dq|wqqbcc(|Ag{}jvGIa7^wA2PUJ==oKLL= zey5o*nl`lhs)%R8tSkqI!@VfaaiS6v$qHUnQk8}Ze$}i{-u|56$s_K%qZmj=ON zV`BlPMqNEW!?{yD1_vTATCmy~WHwXXGpCcFf;MOZTgTDQEUw)ozqQXDTF*+vTkB8XdhGinjS&}} zlZ#81gM766o+K{x@9LU0E9nKHL)Um??ZlSD;EkSNY)s5E7IC7%0$qkSjnWCh!&(D3aAR-2-SF#*qr(JiTPtU)7MZ>`U zg(gGEO}-~9K6oSrlDz20?!>RNrQx2K|29ZVh=U*YT^0WN<9gnNAiN?lMiPH@(9 z+96fOJ-c0GmVPR9FHANR+ZO+M*FfM&rPNea4W0uW;et%&p$sl#qzV2Bd#4T_&&I|^ z^xh#IOPER5dnODlSrbfIlbDzo1maSf{fmST=^Mr$Zy4WaKZ&p^o<*p{BY$h)205oJJp2*8>`VzxAXrkobfBr0{*_6M7 zmkqr%FfhPlBxYbJV~-x*ALODU-~{PMgFP>#qC%k0ep*7Ue9~So6r1qC|7hsxEdjYN zli*IPN=r0K=~QkIzGOKZ`g?lR(L-c9lj-onff>O9 z4M*K$Q;o@CFDj-hsM&0@dGu}0Z?7p77oO)?#Gn^oPgae>U{!-n0^1JiSnWbC^wc z^#258uzT+ML9UWME0CAv z;Dv!YxcCr#Jlh^{5;g-CeqxPIObEtM^6eh|1P`m}QvYfOK!+Alm&ISg^6~0scbJ}2 z8gWCrzoPC<8wFh*4;b=N-hh* z1ey8fE1=S|cGW%LBI$5XI2;eKnD@!azwZ-ZHvnBU%_pN)t`jHcHNl&kL+Km0mt+e1 zYHB2=RI51-hdUfmRI!R7>YHHcDd(%EpYQed_R`qA#(j1lGB7s2k=56qY9XW%{tocO z+ZspIRN?NsH6KMXg^(rDG-hS+rc?#8Uz>l0LvNrrw4FlA38TLNE+OksWw{(TaAE;u z%o_}P9hj+JuXrbP3nXkBK|G;fpRV2G11cdo(^n=kJO$#BTH6V78nt z#Y@kAOilF(-Hj)J{@B-iN#fEmLfB7M?A)AX-gG;l8noou9me56=}ov;}5FtdEZm z^W(?$Bge7h7^;Mk^!;{0O8Vtlon`8LktmS;^t3s0CysD}rg# zFEtl_OYg7-4OVt&gkFbP>M=ch`0!3^D;Q%3HNhb{4$ee%>L~fmwcb?0pLW&M*1ezF z(1QW=={+!Jw$`y4Pdb~l7lvAaV$uz~o+TjTlt#%vIQG4I?N2N2X^$TqlA4;T%#HM< zO3i8i%<;5SOg3&w4q(rjB6hqn2R5e2-BVXygPLvL%z>w4ZZcIYf`;Lx^iE!Sfp-6h zepYbr9iAmxB9l=ahmH4!HinWVC!=!)V?hTiqP$<(;eQRnB~A5`ooTH}F`2Mht1T@p z!}-T~G~h`u+1+*+Ztm_0TKjf#)21ykqf*RNlV zU;^fRB0|qg+a=NK=8N4W_PnnC{$0m(!z8f9&Lab&14E|^ZHrk)G=Bo~ zS+%Er+0AWZ(1}Fx*yq)KZ|;9_bc*OQO3$7>gWgx$jTNztMMhAjlK|tBBm#UGu4r&sjcb z$ojGHZl_DiP5}TDBp|Hbg9yAqYN34k;DfVYemC%L4isW2ECK91)cE#%0XQX-sB=Wo zkJl5t&r=1$fxojz$)leL(oN6txfYK;g#!O&*TZ;jY#5M;BtwW@&(P6G<)}j2h*Q3N znTa44DEjf^FT;*r?PmWR8u97BrFfbu#?pJN&(|F#jfMt~D!up3R)H@o0!0>=lmxJi z2fRM#<>tNwVrwoCaxSO{-qs-lG=bzXz2hmu^z{bFmem+=n|B`1z*GZ*W&{MgD|z&O zXv3W}b6{fZBaaRtEfEfIZ|B{`y4&wc+heu@P9XVpE1-5i1CSyiuzJ!}aW|Z5r%~(g zFuX@$BdbPDzIo_W=P50hE0dnz4YCtC6PSgZW*dDTKmz8!31p{dr?v3uN-1p4ZS3uT zeb=kRge(9DzQ>|$H!k_a$$aefMClbqa5m@N)qcDi6N0^Yt%a^Sft$=l%NJdZcWp0qDG7aGO;UV$`o6abmi z5PIcmb0@KmM|N>|1O$;}eqw%!qlg_&-8F1g(1Kqobq$>l18Y z;I@g>u9(mKn?rX9E7|i#w~n=AX&<{bw3&3(Oz+%@lzM-rsC^ZS*dl0WB*WuS&HpIw zv6+~;B)a#B-`sJu;d)b>%%MqtDn9TiS5_+12>{6K%}qP2iBiL!X{VN0K%qTqOA%OM z#ErnE_CD+r`SAI34_2t`J9^2fdax!uJP}t`1p|d4$t!J|WF5}3vTcWZshIm$OOBx!a^TGPchB1f!n5-j3U4YiLul}oD3EgFIz#l5(9`G&kIJ# z2iVM0v#J-U#RR+8uSHM*6FEY?#y96W^sRUM8>$bAR;+=*vR716f*Ot6avWL$;I9Uw z3y{N5?+mYpVjAPsf{Au~gBF#Or0J?_FA*rI@~>aNzBGPl&ePc39C!%4gL`Q2Bj|l2 zje!|^e!3Um*x0zcnyv96B_-ne>cV`bC&_y~JHi{X04_n)@$qrm@p)AB`hfJ!WhBZm z6TFs)Df=3E=v%3%f4(HKhes|5-ly2keE`Yi>u72wzKV8knF}%*`KE%wk*ACBEHOP= zAL=!amwfAUxauLomi{L5=5oz@ySx)-H_C1WAOwhi!VXi92KP=G>d1zGl)*Cb%B{~G zngEQ546@RiL)EOnV^XbL5GbHez5~-k8wh(Tban1g>Tnn#4>(a)thBDX<#A56}?C+ znsa2+$H>LA@Is&h(BAd*^`#pCHEsrNVag6)A0U;}walPqzC1Ey3g6m9i9x5$!)Q>t z^JAObe&HCu4#bW{;QQ4> ziiN&a#lB5B^!M+rWX+y6=L=;#R7PqmqjN=arB&VISB>Eg0)4*~BuO~h%n z#%Zqqd4|M&=sl?8pp)P7w>bcc^Zn^sC+k_iJxSG)AO7IuMva+3dUiUB!SIEAWJ=kYfF-(rM|-zwJSrb}rjK@lzqCRiJdBq{N^$E^8Al zf3Nw1x5ae@o)e&|qy!BFsdLJCyVc*vWZFBvo}{%+Vw z=DRK92x>s>b{91ZpowZq8mUpx;Wvd(KWEht3;qBFHUd$|18AzFAqGx`3m^g7+1X{F zY8(ut$Hcnc64KNuG5@p+TU%ZAe9h$M=4KEXC;wm@>=!Li3$AsT9xwa$ZB!Jp2% zD)|5u6%7JVJ%RR*KlnpYv%OA(LZ{rr8M|^_^**aZA?SeKXZb@uxI(|J9IbTK`L4gq;QpF;83O>N7SLktUpn0q7;?{#9h z!KCf^UR>-aMjDm4)ie6rflJM1;A%kfpzGlav@XH4z6o9KWKC$Ct339a)8=rAHdyqn zDXAp=z)NUiZF#wuhnstbQg9}crK#gULG61f^fQi^G$W6yIX(tb>Fes& z@v*U4^9FC-UJqQB1m%%KSXPP`6BjPLZ-zb(R_YbnJ8alKFp3X9zkU0*;aSy6#q2PP z@&PFG*#os{bzXg+g#sYl&%^mDX^L{$#81aGw#JG(@PO$M14Kj|5O5@tx%JQ?-3p64 z89~=Rjfef>e-K_y+ac@v)X&+;7J+Uxop(@-<}3I z%4{NYq0~3W=$$Gg5B)PTg^l^PP=sUS{_k7oT{!J&fvEs(kx_$%|D={sluWfNyf9?V zCuYpf1C&A;pos<^7aJR%78_sL=>t{xt?fdFCd<}JZlq&??TE_<%X+nhuYB@q+>Um3 z=XDJY=j$L^w@CON72G807y-Qefmmzcrul1d7|;Z$0M>p;KqK7ix)dvN0^aKc7+m4# zwX+b~PRnxc*EgI#?0Nd@gmN4mDaL_5_8_Y}l^8XhKY<8=uD^D8)(Al59VSz)8s4b6 z*)hk^5V0ji4x#(8ifL^o&X0tJKcgL;w=-PBgr%i})O~$@k$SC~k_t5#iyUS(bj9QA zDa?g(312I7*tI#M7s1;!0xmH0L7iGHLRbP>K@qp~ilAN8Cu6VAVeSm&D)1K}P0GJXD6x-Yc=4!pqBdUnfy}=I8XkHxN)S70mk`u1w+k>}f;l(Xo z8}bY&0els2sdqQHZg<2Sq)9`aHvQVdUK{tQ(Dy>j6K=Ef% z%@;H}4T9SuIb(H;ai%h!Qfxi26?e7SJCMt(chrxV}x z4(|txL1_6S8%{vf$w?H(h|Jn4Pd033+J&!Qk;scX)5iWDj056MWd)O=9lon7xL!F0{|I+@l$w$cBoN(wZOWn?N@~8(K(n z1F5f6i%UpLb)rRlOqY!Ni z&Y$#>g+L8p8hBb+!paY+sgoDLNmsrvV4<`LmX09c9~z{jqzNjl8no@O@7I6-l;JC= zfigE&7?pi9Py*_~!m9)-L4G`gv{5-g%$0e0c}dVuNdB$q8&FZej&#iezHt^Qt?a0l z7lNfW{|^_5FW1O-Lldgstu9~M%*t=?!Kltz_GOEROswCcyI3DUuI_gkq$IL|SF=wa z-|1L6xo9tJ@C7K7$O;1BHvoFhjf69kMm++;nh5w#jE+9a0PO7qq+!HMU0vO^V_ysi z9r{edq6?B)BvA4}2@9HX!q4cOFxw-TP+#=)7h_+R1vm>r@^N*r5G|BJ{L`RByUjx2 zK7-zJYb6x|j7gsD z=JimE6SWj6@5tW$dxI9^8Ff*nuC5Bordiw-blojX-7Uq;T`j=|5`ghP;(-bCzyvh; zg~T2SiV2GHz(mAgFp{v%+uH8`YvAByVQb~{|2Ht1RPO{0?%!TP!^z6s%hc5plC^L$ gvt(3uFtxT+w=}izap|_at?h=C6;$O*&vVZ6obP$glXS_#L`3MA5CA~L%+#0+ zan&Crh=5wV1KYPC#^*&eCjwA)NBEl?KQxAgkWCChW$%f3Xn>)ZlFb2#JP80J4uBnK ziZKU3D5Pb<6#zme0FpsDjh4F50>b^Gi80v!Q6Dsy-iKy>3Nm#Ffw<{^>B1}Da{vey zni&(Vz27Z6nEOe*I=r`FvqA4m-bcXhQI7UFlCWZY0`^B>+C&K-QEd!uN+L-Zr8xZ$ z|3N_u29|%@IIcYjZm8X+jCJg2t3G~F%-)6X5I$3e(yfuKr?2a7jaC`{+<%vyND z2FbGhKI!ey+m(8k=Ex0^Ro1y7ubq84%CEFp4tn7EgSZ=`mCm8~zFeJprCsq4)s9V9 zld`ni(3CYDe$0}qH_-j~SZ<
|h?`pC1wdvkSRq=Gv?R2oZbxZYd+NF@a~j(%a6 zjpJmxCl3q`4l09si(Ep7j9z4sdj|l;5^#g2u$600qk1ZFTk%PWj-4V&iG4ji-}u|U z>x6wud|kjj7@ns9OgXOu{U-K8qml&B6c^aD!?N+P?dy3kROO(gdDUD-Vh3UHi2^$t$vX-1Hbj~TqPCFXlph>O0PjDCy< zFfO<>caNNYb=$FX0~Qw&62fPo8@1^d-)!#n!0C)QFi~XQZgm5W#VB}N#mGYuj1AkC ziP|VEP6Gx+)N4oI;|$X+ZYFpiktc$wefsq2I`wR-D)J~OsC9W?h2wa$Et@P-a0!XY z++$TWmq_a3A`~Run1iOEDJ7dyVg*%#e8e_b5PL(Yz+GLwLRWM2@s(C}4GoQXt%F9^ zp_sV44NjGKb@dH+p@cONHN1B2`c^+s?QkDxqRn>6V~;5v`QsY-HF^!K%*^m>Gn|x_)k(4-fB{JZF@_UV#L62Qw1Hr8+H7}*%D!-0 z2=FmpybCjhp#pN#88&ww^NmwvA?G|G&EFgeY!Z&G%PGkKvY_H3&sfF`BFaF*=_r$4}6ZlUj!q7i9OYtfns)H z3*U%V#iZTd*Z_0z{`gbzw}H^wslQS@Q-gb%cF3AmD4ZUL^uC!XTl8Bj;I8{P{2nfp&kK6%B<@k&-s4j-!a zpgHHa!Wz%d?Nb-zH zD09yB^YCa#Fv#U}2hw))%C2bi~nX?G5b*LnLN-2>0)1a=XspP%FmZfByjZ7snIsO)%HqzNO0rpr?=}#OD_*m zkkBhX0qjWMA6k~K>>7bfrzFOWA(hY04sbd3hYjRQ!Ir z@?`_#WE|&(YSGC}6cBpBY^GQVIF4M#x?ipjD3;Q>cB$vLTcR>0KYB$svK8&>Ur`o+_{+!<@U}a^s4*3gKc>ZtQ zX{*Ds3uhL$2Itkl6RkUoTbs)hrJvq4|H9+(s3^XN@DPH|@L5jKm$`vS8Eu+}U0`6~ z*4m7TRNrkZ*7B6Z9&9RGT*0FMzQS({$fPFox)7d)PJZA1&BTPK+|y=5EY{IK`*>E_ znt|_}#owss`i#QTwN!O=zo|1GXGdGJZTT5D5JXH-S}1!3!UYsI)4Dus#`MSPzIR`M z0Jb;oT02rl&oO*O)K5({bXo^xh6N8`MWv;sLO{LyFSuc9{XXUpzhL7 z?AohpWg3K^N*v0q$zX}ru%!8WQyvcnGROos^g3GZym&+7M;9^6-xX``kcx(U;28P| zg>LlBFHhE)?Jxtuf%tHkCc)L=2x{Mm;$VA4fmSp2h~J)Y#-V6Nj2$;Q^!)-z5N3I)M9!zArNnAq>po}6y1q$G+*&nv$9tnIfZJ(SWr4Ix5_j^w`-x`uTC=sQ^Xk0Nn$_ytA?ko=svm z8=Dgey|y%YtXd&iF3Q~cpCYoNaxuN$e*(cl^Ao4)cU1mJv_8$eSGl+YVfGx)$%nm2 z<5nw)L!S#eJZvqg9=Nob{}sJ_e1`@F#y(Tdtuqi;JlI+Ar2CYph|4WZt^;K~fEOi* z(q8Dl(-zv3*cn_XC~2_i{3zWz{ULnp%)$rq8h|&+c-}qzFC1$+l`bKD{xypTqS05s z3MJLA)}vvtjY!i6T;(U!GBrXGW@6xPe#x$et+gqG;At6_zu@*4PKpGdb$w~leu?Lk zqqu+f(AN4ac9NKrmYNC!uZ8v*MHdvPJV4({oQ{tdS=o$qX^4y{!px(5{ne|POvwUY z55dcz3~DD8J)DY?>i`yGC>P^r)wv;v3MyI!kSUawA3uVp)|RHk;?B#+%jYb=5xNN= zR{n$anmgU^wFL*h)5?-H-9HC5+q+^Ku`H6`M z?UB@0|y1CJ{47l7)X3N%)GwXPinsbKEAsnP01)QEPD9pe4f-1 z8JRxG=pQqJDaZG1-$Uz0me=oiK59PsM!VhUsL;*m!pce#G0!$}|8fet*-Ztzi$QH3 z4P?8zx)Pk6okvrTqGH>}=nM6s6O8m&v=y05UQtSYZwBT*5lXp+n&Wa}`UhX)*dpq3 zDb8moGyNsLPC`IJ`-_m13ic5Avv(=y1&iemjmXBahYUr#`}@@r;^TF|kFCx1+FOmE z4}-GcZ=ZH$sm?Uf%F?(`l$9oy*XiH{RnU)$qcyoT#lEtBYMW zEn8cW$ovjd=zs>jD;;+MHk-}V;nO-o)DEUusa6pAF;=xAt$S4&d%!?ZQPHi+5{hvd z4XvQ`{KX5dX2S+Ld}Sy>2Z2}%HZx!Q_4766LVcKLz z-?E516&6-EjERJ>uv$M~U)bj6W+0ioC}?VGim)m&0w?$HmIL;y;RCdwAO`Ot9tMgs z`AMT6c;-01-lKvWRXICgIL^HzTC$RN2)#_-RErToe0==ij3&p*lgK0FDz0W^beEcO7V$Cak z_1$WGgqyZ~?Un}JT>5(7g3eT9>W8s~U;<&PcyoPu+gM6$V<-OaaV5#Hn}IbhLqiGj z{^K({bqdM5TOB2q;JTCc{oav;y0Z`QJS37PWsqQO^X{6c zh{%?)jt-e8-cRdIvWjE7<&qH*K71B2N<4v`o@CMIzma-5=~~}HAyAMKbAayYC|!?; zxHj5MJEs_XZKkS45}H#k<@j;NT~vMtonI9YwLaPNOghox;TP2`wwpo<$J6PdZq3!6 z;v%|!z(DxMJZF1lbu1_6_~Qz~KP-CVlirKm=Z-C;#pA5sdX&?NL}FIDCU!=%DxTCQ zF%s)=${Nj zeXd*?_p8y#WNW}w8A?iNi4|P0z-IW}B4~fL48~|yV`F1>;1z=eSEYB9cQkZ?loH)S zxuCeXSS7Hgf7~<0IN06Y{qxgGeM228$gmVqAq5IzPC2>JpyMx`5@G`s2Pi7erQl+NDBHo!e~ppEkxpbDfOe<)>b1fRQ*U=KiU%FO zU2MdX{Ur3iZ8DfsJ#Bw{`xLHtCoa*Q&r*-QKOeF&r9T+Hu<2aoe_;>%y}1%>LGEB4 zIH6Rb-lA3KRyw*A+B5%!)85P~tKg%<{4GAOr?=NLB&0gmHv^yH&CRK(S*q8@eI0kf$(TndrOpY7?S#X- zqK9R3X-J&63DqcCIQmSZI~;cBoeG26#Ue_7HO$bM5j{Oc`W$}QvgRO*6!CZBQ-sW( zwMyRfZ5Is;gW73ZBc$gn_Qzakoug}SSGj|lxPnA3kba}8tIyXB6Tv1YlE=bsHt$)d zZ(D-1=Tz-qTelGH&VGD7oN3i?CW3TK#MpVBckcA(=K4DKe1Uy;BZ*%RTx+s_sL4DcpXaDjMm&MUwJuV6{U`QXuboY#%(T>MY*2rMsLLNd?0 G-}*01JT5>0 diff --git a/res/drawable-xhdpi/ic_mp_video_small_light.png b/res/drawable-xhdpi/ic_mp_video_small_light.png deleted file mode 100644 index 00fde5f95fb9d885dc4c22cf566080d5591e74b2..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1667 zcmZ`)dpz4&7QZ1i30s+{U>gy~bSHy`ft~ZPfj{HHqyvy_ z4uC)iz$e%xm;)dlzUB8I033?|(2u!!{{#u%&^Y~-I|M#&&GOEgYp@3$YO%;GQrBItw~Kgp%HX1|?9xAeqDj+Qnt$GA21UoWt%7WPc?(cOZPCIB1h>onEb4yG}Py?UV*LM@n zn2v95tnUWNrI*^yO@=!b6&3f$#R%@}ZUk@rW0bwUy@KahdXxq=^%7}*>e<=H(l^%D z)&gboD+eDWELMDhF-E4RrzfYR>;ypu8u`p65@!A5VkP-j_jJ-}wg$NAaK2}(J#Nnq z&5VOBBe@+2QmQJ+05C+Pv)UL8k@Dt~;vEpkYP_U`uz_qXeXEq1_fwr1#87v)fU|iI z4W14T2BPK5MK1$uW^D-vEi6>#;=K2h4~QjC*;G$7fD`ih{MbR!5iC1X&T}r5&NL!k zzj>Tn4P0b0nT|_-vD2d#EXY%E`Xhm0WSnNEGg9i5SnFSZv9FH`y65IsU|x zq(_tA&iC{4hc(N!U7m+a_JX+J;IxwIh-AJg(gKa9#F*(^)z#Hi%{{B!I_hP9?G-?k zeB_D3!1quUr8ZT4F;$yjT1>-Dv_9 zmGRai1s-M^LrWb=lLk$n=0|s8kq9xft~P2yPJSEshw4X8F>0^)Lb&AqJj{m59O%t~ zJFnM#bX)*lXswmeqjXAGm{afOB&AXgodj4c@GE#RF*&(G<*07^J}Owl2+32)ha(LU zB6+)KomjHjiS6#kf$Ex?nqPW*t=1kk_H2GlIrC_n-OCQ%U6=u@ij!7EL!|4{Fz++} zOT3uX7ZPi8ZfhfE%IoNmS@zy=12^SJ{kP50)SuxEma>9qB zx0)P#<9aC2DE7j(D;ixZr}QB!Uy+2ho433YX=4e)l@6-`TX4T=l&i$x+}E6 z6PZ_tBlJv6(uI;Fxuva-kEt^L5)c&~a+b~e{{g!)pu=#0 u;Z_FkXf`)7I5rfxhD4tZ#Z#k#!$O&%!6Cf!eW6>Q0iaSCPy;#W!v6r8AnG*$ diff --git a/res/drawable-xxhdpi/ic_camera_front_light.png b/res/drawable-xxhdpi/ic_camera_front_light.png deleted file mode 100644 index 53616e487e748836dceb3dc922ae021b5cb93bb7..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1859 zcmZ`)X*kr47XKTJeJ0Ay1hx?r8oZs1g&v~Bn;hY4UOXd=y3Zei2ND%O*L>}UQ zt1y^nNo&>SJP`22T44d8>Y>=Kiy$xN3m}@C0OftKC0?NHh9_D9z-=`Eh>it-9bPJW z5deaa0I=)~0O%|Lkfr7{+8Xf+U{?!sQ{dpY6ue-i@-iY+ynO%<|2q@F_4pX`x(*Xe zv38;3YdO95oy?AO-Wfi3wnt?nCJeE7d=WR(CTEseE?BQ+`MTgQRW;n9kbS;m*`?C9 zNU=%L@=0Oa!Y0#0X0o&dTx|3F@OFSLYY-K(gCcDYxb4s_LOweNtqfg#7;tI$X|T!# z;*Vv3FFg9{DjImpYOV*Bf@EW?*LE-r$pxpgM7#;H%NUABS1h%MrLNVLp2YucN9=MS zpW#YVyB++rwsJ{z@XGl0j`P4f{yThqhF5*RlXESx*LD1_SQgavymHUde*}4yM442l z7l0N)cgwg9b@uYnhN)|nJ&jAB_?P|=cg9c!pZi>3kPnwXRg!))t~WJ?a)wu?=@8Uw zn*$((zwrCyDG{SErt}JqZxx8@)OTt~)*{h|QMy|E2YMGQlYEBy<$@%SsI5-u;rUBC zzO(Nu8!o7bEPS+~ts1ubrg424(@eWRJjMNoB4kV=-#$@z9bN4?WVfBrpLjc2<0G>o zs_EO^9*zEX=sSpw<#;L^H*>CJwoAG-vQ>NP=Bu~xUF?O~b-w`7h2D;vW) z3f`2g0XezuHWUBM&eyb_@v^ut-DeaVuiaTIYUDKo!l&tInC4yDAh#JtIGpw#PPi$&wp+gwg1kPQkBUr~Cj_N>YfPaMJ9lV?)0+cAaASE9Q)H6E0i zZ<~y^&4iIsR)+F>cg1Hm&{2_9Ati8L2d3;H0PX)A+YH_n*`;nX;HNZ(1MEdaQQLIZd(V495*} zamQ&eo!Ces_%LoWM4Lcu>v_fYr{`X{t{cgvS64$YB@o&bmB~{S>|i(}LnwvlwTS?y z)cTS&M{8@Og&R*&DvF?m;Y^lYTz@slm{DUfxWyJ>yFncIJ^|Oee3<3eNXNaJ4ry*F z?4b&CNiu1r@G??4Xzs((*xriD>(8Xpaq+V`vSL5upg)XF&YQSRl&oe{c;;VYJ1W_GP~Gug znV)i`JGiwGInFTyy?KIEC|8RM*+YzI#4kwfa(pT@9hc`_KQvk;6n%v(BHm6r{?erW z4RQq!KVx*Kl&X#_J}268&UO-x2&0qLx5^OKeYSndrV+4Hti&aOj#zPITjy5M^RC>= z&+0RxCNJkMCmE&FV8jG(ixq*Rs^WD0n9JY7tydOagZx8Kb*Xx=FZ6nBp<-8RkWOO- zF)xiHpjnIUKh708YAtUKlARM{x3s=9yQT-J&*5Z53)#azjR<3>hQmmnh%eJJ@2p}< zhB%zypn!#f>mK_$*bOSC_x1#$wp3G|^$L~cJu79W`U<-i5(DLWUg;g!e$bnR5h}Kf zXy5ld0c$YgZ~kZJr+RH^PQ-;g-XhgA^nWFPJ@Ad?9bsxVBm3dLx=OL;>fS-p4xR2$ z5Vh#|1_c+q5!cGzRyF)tD72Ccch5*CQ|BR7LrMnLclr|Urr-Z}<|G})Vn?P%TpqkQ zQK%G%_NHV`d^r%4!^RbzZI2&Tlpusypj9b-_a2SsZNd$f4&_IjczllcD^vr`{tR?? z2_&Q4{K-53bdcJp(@5>pNCP`st`1w05Il`@``^cV_qAbMN`i`Oa&0 zl`1L0%hTVJ!{K--5@l+3O?15;Z?WIxJM{_dGKrCEnR#?7W5vxRN2;fFB%q-1G*V6C z`kbYw$XOiDn-+sc%V?GFiwT@TUy#x&RX`kVCIO6(@dy}2fG80k!2}`%c^5!oBmzQU2o>=VL@bCBBay(+1G3)C z`c$!67C+>Rl_X#~!*B8`kfCIQ(SzTTi0V=y6r zVOYn*2~xll=psm-P>2e70#txS!bDW69zlogyb2G?;$ww!L8J^5$Pq*yAr!)BY;3$p zj>e)=d7NMvt1wy^+(?kaz74GJAuRS!LQh;2vL_T?O~!plh_jqLcC**PgMFQ3njUx7j<)z~_3 zZ*HgG|6p9(y^AHcs>jBjZZ7fJ2jpGcQzr{v=`&;0cwKnQx(gD!%CIZ#`2v^U?LA)})6E4@VujadyR@*N-kgx7_cv z9-ZABlOZec@Rfpz+*QEXV7qMH2fMg)k4LQw|3rGeyR=F>a>lJoPLVbfe(d?Z|MiF+ z<3#m#eo?Y&r+G`M#&2CfTJR-qSldX&XGxB0rGA=0&c{`LSKM!@xff~%<;6WWAG{sl z_H~H8+d1tg--mt9ndiBwt1Al^fk2UNhLA!P+lp1mOC3Ek;@4lc7kgCiSwQWxS9e?} zQDD%WF^x!!^XjuVK5|a7xkuZ>^C(0+Vp#!KIPr%S6E6nHd^?Zx?l&Am`kVpjM9%o~ zLc34d8@rdBF%6nGE}xZU&nq0|JW#VD?YI(#M%xq5A@_sZf6d}{JsG|5WSZ7{!iM}6 ztpV;OyxFVIVjOAC-tmfUgV|2so9z+Hil5b<3r%ozKi#sv$=9xqKGa|E8~AXv{Ydm7 zb)9Dg*DEMzv$PXfT+#OXfd3P1hx>-*+kd*dw4wSyv*ZNkwdRob_+XX|T1jcU7n@G-IDpIY#cV2Y6<_hzb$MdHKI4tao48)-U290;%B}UkPLs|H!piPww^R9>@>IqC zskw8y=S*7rU}0BEPia+O=Q4WI+U+;4I7_PnJ0pMDGI`%u`5uQaKc=h-Rcu%7#ul&7 z2kzN+_4YSvG6s9pM0IRUE6T^Z>&pBag068Cne%hD)`snEomq3LoJVwb#K_${1N$6z zPv-CJudoK~yiINS5^S13GU(g3?Bu$cfyoYY;Es-snGM*T$(NdxPg7EYxE=d9bw#_S zwDeYaJKTc4$?9(x_zM$?NYVJkBmUasXsZ40;MP#jfci4%+JxkjwB*KHlKmIY9-jYm z(S(qJhH2BE-fvDi+8wB>>Dyd6PD4%cRw+*GYPQ!-?a{T$REt05_EmV~`b?2lhWr6! zymP+3aC02LNW8vr!|}Oe(~nK5D{A)dB{rU@wfMAzbx~JaKAaeG*YNkdz2y~U?a;@b r1FKJwDdUP?^u22c!m1xE_{5DPjOR@Ea69v!>yM|9C&}t!m)QRS>r&>` diff --git a/res/drawable-xxhdpi/ic_camera_rear_light.png b/res/drawable-xxhdpi/ic_camera_rear_light.png deleted file mode 100644 index 8e478e3367e9bb7caca98e777ec2fdc350d1c7ef..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1534 zcmb_cX*?8o82-Fm&l@tjHMH;zN zxiU4%@^j}1)l7$D9POAgY{-uNwjcJ}{@&wx-{<{3zvtVVe%uu+yJPnb0Dvsc5#ugG z$L*4m5Ow@gBUS`rWV8z!fch&k8v){?9TMe^wF5Q%>O9e+73Apd0>D{a02nC%d=pWO zIRIi10L&8su*wHOg;vz!h7uhl0-dlJu(cf}?f0&U7-^cLZG*E3Cy<8xnBN)7cDyyeZ7`uAOLKjhK6lFI8RoC>Xot*DwGy_eX=oW{$DyN|{h zF`&mF)QK1g)!a|5&?OiLc6(~&MBqMngQk(E5?lYn^o(rgJc}?* z;f8Y`G`(}66jw#qa2bP{9pc6y7NT%<=E+UjqSUqknr1%QkNqq{^`;BC?`6a1k6;A+ z!gVn^$9B_h+L+08J89a8>wEb=*{s1(BM=m_YFR;-k0N__<}KJPvnHA9*4GpU*% zs77{;)-X?3-?%J4h{5l{Z+hTQA$JBvy$Bq)#bbq2M$EGV!^j1RPij+|(tI7hEJC;U zNYKy z1f3A8nlbB#Wi3!7JR*$jr!!&HBy@qc*<$x_jlqkH9E(!&fvUAXiYIg#h6Gug2u9t} z4v97lpBO}(pXsZad)p7I9Uej~^kf?F#rOuae%sD=@8~b`KXCO@mYntcAH;1&X4Z0& zhVAnSf1QV%0+v$6vd=dj%C^a&3Q#A?)>#yV=B+wNGP`heuE)FCHwGezu<$ND{N@8+ zO|QdT;{L00$8iQi>dRTW$yjJRqHKW4F#8|V5E`saU-oq2#O!o7&g>9;E%}WrmnnZI zx>_|mMiQG6VNe1=Xp0?@-dx0)zWkOtLGbiGCZBPUquaT%`07gWbq5|NWbBf?ph)G% z=eVbjlTM^m)>%7$gEda)P5i2YO?fce=Hpz;qS!gVx8S2_VZL-H%lZNBs!ZahHlw$IFZ2UK6I;eEQYhb}0*KEXm#vcfCzfTe_Go*U3k@#VS#^%)-$b z^{Ln)iPEN8*bOfb;@@7u?up9pSGuDQuQY@Y_x@x}Q?yl)KF)C(jxP?Dq~9(tX%SeR$=a3ntd*q{(oR0UBV<&N3Zc4N zs9RiHpBpmXMf7uhI__1F)1dfz0XnG6cdBZ@=I-Z}RZ*p)hO*i0K94a?{sHX|iPa3P zCtvChT~Q>cpXR+$MCaz2e^s5nbwRiPYoslY*4lr5+@YM*9I?nAD;XC+2|C<4VH&H8 z^>g*ck*#?ByjVHIn`>o8Hs-VI5SnbY+a^BWn;q&AbO82_OWqMZ5IH5C^e(ri(9{lOsFeOs6a?v|&uSwWP%8als z=hM1S$ZEVlpNLC55RSLJrWtKcp)5aIOV5Ucd9F;B1*~-BF|BKu-kvf0;t#r-yx)+i zgsmvA&OYXf-keUfy-##-Ks3oJh)xm#FhiJ{A4HfQL>%@sMOq<|RtPiEY=uCqteQ{# zOAtv7ri8@*w;+X?MHUIvwjDgEA8?8vacmMzZ diff --git a/res/drawable-xxhdpi/ic_checkbox_outline_light.png b/res/drawable-xxhdpi/ic_checkbox_outline_light.png deleted file mode 100644 index cc77c5edaa4d52be8ae5c29e02c0a1cef12660f9..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1576 zcmbVMeNfY87%zefIt34u;n2kZH^jC1Xj+ntYD-(CYNack;5I+fBrO5jlq8jcAM=AD z>NYEE%DK72jp;xSZ*F?y{6J4{;!bpOehs}FPVr6@r|0y%iEb&jY=3zEvD}lq@AJOT z@A-bR-ZpR6m{Ai)DHMt^mOPVPUh_h4>NB#%TwayH7F5l1&{d^0!GJTS133XwR^VYJ3J7@0ydoLUfrENUc^^84 zKwwZrD%F9{ohq{003$E3fJUWJ(lCku*@OzwU<87^37{~dh7cG+2_=G%ST>1hf$#&$ z)&#~y+D$XVwq#BR7E6+kgrJIw3RQ(##S3l-AqWD3Q3yqqvV>Bs^h#7f=@ru=8ceK6 z3!G2lcrOsrNI7}GqyuG7pNHV_S*=5gy<#{~a>}3p<%19v40$}ExCW&~$0?+a15z|X$*#8 zr;?&sOo=(wtP;mjT#2EWMhnx~E(Sp(dR~M_G-@q@!@1^MOp739wSh2V2H2dd%{Ag? z6F!8sctwfw(rm;wC)*BV4KKx#MuDXyUU2YyS)>bW#k|Ce#k>zN8i7nJMRVQ|KQ&aN z=OLR|fm_NlGX>rQ3?`Z6Uf=-3VvH7ZqDmJFGfIr6Q6=sq2qlVQE(Xq4yKsyJ!+7TZ zCJ&OGfxvV{B|_i=KkBOE9Z7>+L7P*dC$$p`5ThXCnsc&EN`AO@HE{y z_3*)~np3@72UgyjR9)J+BCjj`>SrG&WVAkRfuo|4ga`%go%*Ffd@#N?GxK80wrkzy zjtnNFX|iMf)spi-MUXA*?(a^xBF3IPMHF9IyKK8A?gvxquDDx9us{rER;e1k>I;^3 zvAL3s<%6?rla3uLao@6xKU?l~?p(Jet8T9z-_vTpdFI`3_4PFu0^5IX%fuU- zD9wh%8>vl`PQC%8;YSoH$L~jVWRCR%*YQtR@9D7(-&bQC*O4_=1Kh^7tL~-mqd5M; zxsM;b7ns}>{BibQa|;(e7`4DQFr&V!I%ngJ4Lct{JJB$#?~{dFlbda+1^Ty7?_bfe zyymA1Qw}G6ap2gvB{9Wmy)}-_((m~2>R4t%64qo9A{CXQFxyC^6-& zxTZCK#&AWC_X`tZ#Vx{hU1uUn0H=3!EG91!XQF$}UHfCxqpFE4#k!SXI*U=)u|>ubj|_DXFBn zwW%EtQzk4)>ML-^PYhfpMv$vjIWgp=w&df5>+jBac1T^rKigwC;(ypUg(+BxJgllN zYwls5j$c;acQE*+>1+Gt`OC+pEY4fB?$GX)Hh=AXvUcO@S8KyGZ1?L?BNXwY7hbx( Sx9xK1-)%9^GqvU}S@RDsy*;%6 diff --git a/res/drawable-xxhdpi/ic_image_light.png b/res/drawable-xxhdpi/ic_image_light.png deleted file mode 100644 index 16b7e7bef345a6cc39489a044ad6309f79acb1da..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1669 zcmbVNc~BE)6kkC=BUnq3OVK5?3d&})n*@@TsJW0-10jGM#g@(PLNbuuuvyH4jw3-v zu^rE9$66{1RC*{g9*ngfMMuP==uEYCDy>tc)wWa$%AwVv8?N>b$3MFB_WQo~?fbp^ z&DNMxmj#I;MF0Q<8B_EYVNdbCQ~ZTB>(8Uhge`(I@l>&_pyoj<; zI9XnLiAn+hUza^Ihs!aozzD`6!M!pP-r*F`0FbnVcj81b#er6;$WE)pJzt#_gLYCa z&Q_SDCZ~?F*;6W5Dx)Gblc*>rRHS&xVlauv1O*O?!$ICrLc1_tEgsQ}31{y$ECxqJ zxMH<<)Ttbk8PqW>1u7&8h>)TPn5dE<3b_hF;z3l3$Y4YYqbdkNFnJ<|B!FX&Sg^*D zg_uR3JZ4Ma)M6XQIWZV^yWJAEOv125FrreaU?~ctC?rTgu5y~gd5Ct!jBC(SE`qf? zIXgpxUX8ewDdW^)!PC(Y98QyILNVX=Zs$o?dUyD%z?m6OcF&4&}Xmx?D+F?MnVBs`HxOU3S>J2k&nwTJs zAA2FID>!IFY~LH6i#}CNU`uT5no|JNdgT#T@P5M8Ut3MhgYA;K&OIj%oUaU0+X6ZV8+Sj8D%$+~-AUW7 z4wd+yA~vf!;!o_lH*~yU<_&}TLf1o0;`iC{gXjOgdAY1Kq4l*{mhOmf-?W)mF3Eu6 zNp~UwcKQ{6*^rac5t;FI|4muwkv7-to}t#Z_?`4=i+M72_%Q!Pc7f#0xPf_fXZS5_{bu#Ww7_md^o1RD3-q;n z?xl-vbVaA|^vOD%Gls0qI@9|AK2dvc_RqmNXnA1dTMo+l&AYW zSpLg5x)sxMA50Hhqc}VL!=o#vn5O3THm`qbZOqx)%J#k8`zXS&xSmH^^5ZsnVmEmP z;**9~)p~YAA;%grt~XVCe6H2NH|%TorzRZlQnWRPwr>xZi+Q@52I{`u=5DK~-MOgo z*3xo)L;JwS&QIp_uAVdfTnPK5r&RgMX!%CgFE&U^Jxa4{HlN F{{Y-MWcL67 diff --git a/res/drawable-xxhdpi/ic_mp_audio_mic.png b/res/drawable-xxhdpi/ic_mp_audio_mic.png deleted file mode 100644 index a01388bbdd7a037acd922d10a1b5643fd58660ca..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2446 zcmbVOc~nzp7LPiuR-_88$6)}C!3^NxC3)H41p>w-N`jyUM5rKSc?kiskOT<(;!%iJ zsYpR9;D##_q&R>_HYILQK~O+e+tdvK1r?=$FfS_3%o(SD%sc13@4Mf3e|Nw4Jl!Ur z_2vuzwh)CvnR7f@JY-Eay$ilXzDB!~naE+Uj?HWB831(b7Xu8%!B!& zxTssO8wxc~E%gh~1bAv)ID*3_)Sc4Y85~-Yyd2zW8gO-Zu zn5`6Vg13?hheK4qXN7)p_vL5jHciyfRI20(asPKq>v#HbV3seAPEl=@I(jzK^oba22#-< z9}J>RB@)wktaTrCAru`GrqL*AczkSZEH0LWQ>Y|(5P~2)fruv(0fYlk$H_H(Eg)Ch z%}QXwYN1N1)JPR_v`HdgporGcF^HufT#zZfy*~+-t3L(`2^n6?SK>h&0WXu8{Cdx= z*6`qeneka}wO^bP#`9pcB3dOx@*%dH1tYQhb44ab#2VU0l@v(|Ka!;oM$2HihQp#` zkQB45Ye8O_%Y7Jj5glBb25#1lLtUtxlm@1gBQK;SrGr7|6RkV6hIz(0qp8|8Zs_Gp7H7`?Z<0Tu3+eE^ z?mw|gpB?6z?^w1;P-s}LQ()b5?mLbCY^c3j9#^RwPrp_?(u0*Ba3LR^!dt%O^aelm zKpD-JrsNv#TJCWTh|M#s)*U;y`^e^2uY-jhmC+7;UGY8V3%NY@s28^I$EO7XdX0Yz z&v2tBR|)6)TI)^fMi+b)}8N z`BX!4?m^$sQz@Rm-$s8^Y}_CEMdH`84n$qBV>z*!xr@T|S56bH8bHr>IUx*+K~i?hn2n%nw4)}_Bn zbi0uM^&zA6vEO|S_Cq61hk+K7Lwt%OU5oD)Nqb)kvvmzFXVfH|4p;Biz7Rcc%VC z2X0)u8ydbCzm8BHZb6yVV!bwXKZ=JotM@_sxsGx_+qD) zm_^;mnB;8#OIZoQ_g`axi#t>Hu<8sXT#ZM{FO38F6q;O?tKV{aurP`l(5xp`+z&kN@x*PSq#sCYTkrIu#$rW}6turB zn8ZpBty+VXscx;hQc#`c-1*!_{zDa^A^t&N+)Q*q544PC)jDda;Mg1}iy1!JdOCs@ z{PYKkP2K+S$y6e#WP1{W=Nj7_d&}%-l2zqNMMsd$y@Sl^CZbvUZylkH!p0WRb!kL# zV$9cL(rpjJmrc0ac8+hD3N?2cE+n=z)^vs0s{Ub)>JrGsgek(@%Ty z!hc`kxeegCr%r91my2A-?%R;2c*f1T8$lJzNMY@HtCDBjFBRQom)4n2Ciw3f8>^|Y zje+7CrkmfQSJo~W{br#2O)59&S4ZdY8K(yQ)5X?yOF|BP_1f)>;TY;riRvGzX%^uN yjTWC7bIjO*X<^{!hG3R<-`xM4kW9mreLafnP`EsGN0piB4~fI}Vd>X~rvDTD5g0Q7 diff --git a/res/drawable-xxhdpi/ic_mp_camera_small_light.png b/res/drawable-xxhdpi/ic_mp_camera_small_light.png deleted file mode 100644 index 8012fcea97aa7c300482072f11dae079197169b1..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3444 zcmbVPdpMM7AASuDD#x`=D2<`4WsEstCR2tPvCEJ{S<0Fd#xOHxMh0QfmZGJIoTb<( zhY%HsVq3~-9TFmkl4DMbt>_zV?f3n$*Z%Rn*Y&>7^StkU-@o7UJ3QC*o^_-gQrN1o z6#xJQTN{#-WVG45<-n4s>ukj#$)GMIy9u4S-ojuip9v5dTpAN<8$k78Ix(q?kicf9 zIRHotST1ftH+wrgof}|8-NYD)0(cTO0GJ;X@u+k*QwXIoeOMd<>}|~h7?i~zz>Z?= zjqQ0>Okb8wD4*#ZN^zlwvgtSm?BD^Yxd<;22w)1SP*H$CM}QX*V4rmHlKJLq1PuBK zB4iU_e>vr5?+CTx@|jSq5f)B2Mj@f5I3pw$gF_+>p(tY{8i6!Mpm1;`5|1&(BTb;6 zFPOv{pW%&nB3Xa7C0P+*zCs}nk3a+m2O9;WjktUt1QLhCA&gN76bdduzy%>3AyowD z2z0(EkeC8GpT!fhxE$!FB9+Ds5)xn%PygCN0MFk3Z()w$bD$(4Lx`w61k%VD5fHGs zuTN-!(24n9H~tYVa0%ft5l&12H;7M{b!Vlz4-8;Ikx2q56}!^q>GH zM`%kTz$9Oc7%T=JYfQ%&8ynN$R5}v_$I#GBxS1Ks430rzuqMWIQ*QNYh3}c2xnSJ5fas)yuhtB+Bnr=T%xC<+!9z%#LTpyb|5VPGSCVqv zoc`ShlEvSh#N%p*yiNRHC9XSxh zFM)GXE?U__TWksZ1S}30Jc`6tMplSaNL$Hn&R&8E5W9cIxjpl+~L%qE#K5 z=nHPeI}iULUamab8pVsSlq&}dHXu(4*~T4lzyj)hLW93a>GrCdS+D(}+qLGbYIU>f z|A2r4C}mP4fr;@+)^>~0eFTG8Ha(Q3JGK`RYlyx)O?Te`$+9cAN+iI!=q3zo3a$JUvzM zWcLFLLS@K$w>aTM#a&G!oYq2c%WF5j$Y&-#4Gy($b>2_G9C=7pdm5 zsVQXv1686MJ@?E^OnM(b@Hg?8D0Kd%1fr%ksKZsAJS20zOO8_c^0x~*IU47G^~Dlw z?6AwpHwm(fMNZ4%zP`S^v0u+$mUAbfEK;Hj!M32+>D_&OYbvCY>4d4_;o*-z)`Cl$ z4s-{rPnPF#Dz9{l0_RRi(XRLikgh;<>zV;*0-Kmh+!3v7N%P&}mAGKLO38O*?GKKSIsQPp;c9k1ben~$hK9xlpjpLMy{!pZA@9D%)}Qo@tLg^X z6x+M^Rrs_I`?eE_pf}*Uq&iyDIau~Y&Se?eo|&-MPtz_{24A6!Q+~c5wyJgH{Y%Hg zN`5TXU2y0S*jD<7Q{PKJe2B^Ld={t0f2Xi8tJ7(c4vzOx~va+Q-8%Wi`1R z89X-_EOu^Exkb`kCzHtvewj-Kiis~c-241jTlr^+w8!bSwYC0zR%!-U#U*7QK78mI zC;QUpAO5s$wf;oSu(XWsTg9lcD_pnz+@V@w{;aqS^W7nta;#jH#|JH_SKDzr&MO0L zV8Gt7pTb};V9x184^H{)Fc4y%Ewyi9IbYslL`MxV=zDucMWJ6~CG@^@gpx8zJzEoZ zW%=uo#0mWX+4^}@6MWd-z07nWLdns^B{@ysV~uaYTMzqgU~+nTI$7<&3!j6t4%f06y7DuXX-wrQz>3z}Jxv;0(_MS>|^2Li64}$HAE~KU1O<$nwIfUcA zw7A8vwM#GDgBM;u1;2BpI6gkUM=I&2<@MlVkLo$>BJ{)QRB>&fBngoeSJUxhUtf=F z*?oP#d_Yv|hPX%;Yijy2>zv1u{xn5)uKacXZT5uTjfz{hZYc)k8vTCu9wfTBRJvHJ zQF0)z*X8EE40(q@vGs<)wI}~vM6lWH2O$?5RZELYN=i=Ey?2!DsXVNA#-zTtM#@!5 zHKS{?HZ3KkD%sDp+3xF(d#tX41n%ISg+2{M&!miunyl}t{ew^{L01*9-X&Z>Blj#9K2d%<;ajuqxG|Q z<~|=c)|<=DWI1YNsy~lmQ1-o(l-b?L)^seQvo4u*L9xBYQzqo>IQgBw_VJ|H@LaC; zo1KpAlU)%;CW;Q_)z#Ih^;&dnnq9M63AruV?(M|b@VrLtj`!CICF;+w?Ri$O<8PEY zjkmcmFneOH4t{@Vmx{{5w#26TFj@iIkRO)#bH)f5Xp|f>KSuL5`YhXT9+0xQlH--w z5S{v`^w6bFP5;>X4ZR`IH}Mn1IuN96I44>m_xK9>FvYgOo+8(rvCO+4bL0~4U2)MpH%gbgjImZpiYNcfpMTk42(T|T(QF$v zYjePk^94a(69-*gU9+CFx0e#jTh0&K^&her8T0Z~fv#wKN?(H6HQuyO4oP^26a$LG zp0_9z`7+Vb;+Piw(c)&$V-a{;AEot9eQ(e;8%3x+5UnGdn541-t$!v|rRnxk`&OmL zPlR%oE4D|?5S3OvI5P^fS1s>T3Mw)U&b{=N?hZ4<;jP5636`Qe7xqxq$B0ge*KF9# zlp|0ho`b$-R|YutdA5JKoO6Xn+@5}&sV)-}V-JFI8rv!1x`m8h?)Mu#i_zXMCfI#SeZpLQ*W;)tZjxaBN`@ePg zgT3H@GzcUk9}Ks5^l;KZ$S zdN@itvB=9Y$plLQ4R|^G+cO1wd3yUv1F6^l z!F-*WMEOM_j!+>%CUHrAK~WJ&K|wwyA*i6RfFM*rND?9_C?z5;B`C)9pTYw4=Ii7l zWvHzBpT2-6Sr%7+f4G!@Ku}N+e~>Ug%=d|aproXv08~goNC*PdfcS-Y``ZUYy!}}J zZ9&=D&(YTn?(YWkX8PNry#p-3UzP3>bZ3$CN{-wk{F{b!+oB@+m?hYJYuLj}CN z{?6;4+J63q&i{89|6^@G;}E#BfT6P=EWp<9eYPN@4wI7fA{FWrmXDj z>lWzjr0NUvV)|#1rQH690XT?=NjeINIY68R9fTkv;!chb2Po7L;^HVK=HwzIAq*82 zW%-Z1)Bo?{2>^o<_^T=ZkLLXQ6QGWNzy9|*03QDPBsqHn$Hx~qCj$~*^&k+>hK90& zaq#?JA+G;}$FmWPT`l90#T-K2jHC9;tF!0{&R-*voZ^lu)xiAB6`G za;Q@hy2pH0xl&k1h(Af{F@EJbnOLF6`)9A&i|U?ydAalIDIRjrQ7`0=v6GdSITAv8RU0IpQ0- z1y0=y@7k_w_l?Wrbt4ByMY>J5mPI z{iW(UGumwluW8L52Z9m8$LARc?ifdmfr1E|N!&$6$h?(Tp8vVabaZgw*!_L&gR#v8 z+oc6}eX6hb2?`0hN)hO~bfCrc;2{ZYAPIzFTN9z0Z6|cxD|aC}pvJgH(3F39ET2t> z#LttT-}J}?E@i>xbGxjH*HdWWQC$j*CAG*c(6oZ3;hs+OtKO<3SomzT^wF%C9z6vG zMctF}B594wVIU4-wt(g zaj7JrlYnNi4R*4!vN}q0F^ig-n$}u3xQu>MNuqejy8Tq~eXd2@IGXNb)nFWvKFj#0 z_g7opdfM7gEh-FSo42kKOsl2*Hb-_CpIxq=<=8$8NqJ~>A$m>Xn{r(aGt8=+ zVs)jlrP-TBx0F(lJ@qolk|`2U+O~F2OS}&CR>}Evqs^KTqSF3ei70* zYPTiyGQrS%s+*4y5fO6x0sIcgsJojBM&?J1>>9|&XAYy6+@6XE2pMXV?NN;{sI@y9 z>-f{3y}5==+@qEh8c}zN&yz;;T9l)sqdCJmcY8zWtU-q12Pbv?Z$Uw*raMYLGm@Lz z({TaT)gjq}Gwye~v3(=XeOiZqP|?KC7Q4c4L@{VA$bSp3SiDmUss?%TZaL{D;GlXZ zAIp{PgbnSFJ$k>8QBYyRM)-aX|k_ zy`>VTH-G-rp%HQ#3S(19I^Ad=b@BT)oXMG5Yuzq(u@tl-t8(w9_~vSy0}uX$VPQnF zcy0^N+cZtOP#4u#b{cUCA&~=&4eGm|yzF))HUpJ>@DZwHW97bRJBjUU1*dMo<>}Gt zmzFY{`HYFNxW!*!%9o1sFEwPy`{rv0`uavL`aEcp2L_hfgSNVkdh!3Ti)&G%5sq?2 zkG9>C6 z9Cq;eP@}*){?Yf*4TNJpx1Mw0T7@(pMSxJ2DNcJF@e&3@VSLBO^h=pnG1TNPV+E4z z5)u+4OTXOR-KAQbzh(c)6ZM$I{fg^vF|^hcVFW+L`d%a#eqGeq*x2%z=W$v-L;JJu zl{%Kip}X%6HMO)nNL|pyn31Dsu#Z)evn+l;W^`qwh(k%ltgWo9kSl-gFKowwox?lc zX7)C*AlMo!4C7#BUAL{0UuO1~*szhLj76g*C}R|H)u#iWpi`L#`Yp#Mvc0qnJ>R>c zy6S4z$k&{gn6e9&iWaHrU`xjmfFSHNp}ix9y8D>~^YiQP#n;n6e*8flTncM2q-y6vSE)~| z{kZC2tX@oRo$6X_gqvaUrKYFXZsodm#JzH=_Qhc(MBZhOiqYH93O`rF9I88kR8=Tn z2%TmDQ{Dm`q?$(5DaKXGN?IVNobPvyUx~OB@|ca5Q4p{{{Vo?)Kz520hV?y` zKfYApdZIgvSU8U70%;mA1zZizDCNIR`uR|aOknbQ#?&@kBc+>?oc&0q#jc5op;L`y zD;8GmKS_7ZG;J=kdqgqKy?t&oA-ZkFu`wDF5|ZkVvGD?9k#upxK+KeX5o&k3=r`V} zno=C=vyO8nGhD7>l-Cg#3HiohF>NifD?}fFFI9vog}wAG_UTGjIB}!+#tU>nTsr8W zK&K&NtriB{ff?4tlVJiknsI z0>Tt)j1gDv8kgA+OP92iAHxRI{YjL1=LbtSq)$Cl2KMA1t`DZA@aqH!!^SvE1gqA2 zs!rblB{c%#q-cXJHb@)ak_iLphG1KA!>>+$whTis86>VpAm#6#wLk>)Tm* zH{(u~e*4hn^3KPYzuq~$+#-nC6a)1t z13@0c#>vSEsK|a!SsJ}`peNg);;mR@<0=*TK+u3Ib@c6~fy;EbZ6hYq={#s9yv1O% zwf~tNSm_NYXz$0*2z(ik%%;PO7cb(q5C8h zs|*ra;)PIcaEjxl=$T&L(AQD}|U%5)GXEmKohcVzUg5F#KTpslC(oFp^H{?*oO zgJz-wFQ3SUT?4M2QeA~{J31=@Pl$DR%o*^wx8leRm*5ce zH|s^2)NpQn$y#=8w~d6*($ZEC{jv<)pZ`rVur9?N{iu+rl}QA*7*J2d=H{m1JMy;_ z{8pDjL~BkC)I8Ze`vGZ#rTd7uOhlz2rz@BjK`^_ZqLihJxv1buW@}}2TQRLRef|5_ zkF}#P3L>^XBT>U_{ZPF%>Zs;lsm%p=%~@Z;(CERWI38%JZt(JaC z{#JEeA71*l%<4k`^@c^GHOoAir&K1=Y`49L!cI-IYRWCbXqi@uG8-uFA~XV8FWal* z2x+l2eg+qeWNPaS*u%p(HzHlXI5qAB*9jsm*7Wb4?QpiLT?<2eBqLe|ujg(<=^{7M z&y?-#>;!Pd69RsI&8kkJm&W=9>a}36i*jS_WXpZLyEIV#4b#h6VL`M6(o@-F=UCVD zAPh|!K_W`1w8TKUm5oig*p&YKcAXIh7~m`s%yU#v^n&04Lu4W@FZQ*~6%@-0U#ut; zg#c@?79vRMVj8IE;p@4 zT!n?G&Y55Q)s7gV1-?2qk+Q+0n)df=WYx_~Bp*m<#xh>3}rGfcSyVjH;*{~bd~ zxn3IhRim!ud6GtaJda7EGyXh9;`{phbtt59X4h@%?0N=YZF!edcr`-*Z237TESzY(9nC~XM?@ie>g($>hrZua zgv#_m1PM$whtOHkKu zZ7VZ9mK5ZaF>EXHV67}iz*eq@$DUEMY|?C@1-2q7(K|yd>`C$3zgC|e%c~fZ4_GJ% ztO4lAq=CR^f3pOKmcR>OnQpi^?FGIbcK>;5Dx0jcO_>3^raHtjEyA}iObDe zfS{`5m`MuT?OB*DdUihdgi-&Uu?6??&F)O~%wjVA!%OQ2W*T*2SwxnT=gstD5xBhr zp#~@%$C#$i1$Q(DQ%XeDXAten4IwT72I**P3njkVTiyMZ|N8ar&F^0qOgq}lS5Mf9 zzR_NO#YOK3!)dZ)==P-;5lv*Vk=f~u#V>YivdCGe%GItcO-M+n-)O+Patowb#!Jr# z6-Pp5F9FY%Oc_nPF;SwlVz9grL+RoI57H7CgKf;bYBZm+I$M^1RuX+ zj;BL-wFf8DNuJnn2OLt`KJ`I=_`0qy1Gf-9ub2g-4<9){h$PYH0){L);-*C$6__ME zmMh3A#lgX0x%cLDYn*XpbhJ~oXQ{VC{B&(G+5V~JW9P%=&itNk5MCu;0sA4Yr7q^R$BfFz)Zy0P zD5Q(Y$;o-nKu<3_Ka(>%J^dh4`hXdEpLg5vLgd-{j7dc>OhKN0Uw+t!VXta&jws{W zzir_YnQla|QTYA@_Q61$W#Djbl>c~6!I$)O%ft4=E;Y6>H;WxoP zh|^GFRn>^B9VI0tw_k|mJJhoN`CTR(c@?2*KM?IU90 zbFu%U{4JITcc3Mf3rMx|95}n4UimWo?Cf;5Q&V}Y_f%!^b?&r^J)ZoL%5IzynY+QUiafUvFj3U_EBsf^{BE363TxHt0`_*0#49_TqPSV$G z@6FKA5Hnc0T&BX<(EW>6Hs4O|r0baj=RJ^MrW(|@h>uDi8+~f=JAJnSi8k(_Y*FX} zTz4G6`fByigJl5@BUj9T7Q%u=|GP?7v z8&Sr$uli6^bMP8jU#6N&qZk6uQe)^)OJ&$`;N;`ulhNv5)~qdp8rbAcVTAy0!@NFP zT{m|?gR1G_DM}2XG4kDXF9s76bL-tv-w5c*8WyS z_xoEt+j0k(aCCm6_D~4IQm;V5M_~`-WXNiwL5lfwzR}Hq%|7{o^y)uJf0<@9UD#Ge zVuwTrMEmMT_xub`-a1y{dd_gUSM4z&tk68jqKE3nhRCePfRaH|POf0NwZf-V9 zv-)(wpEvXnf4?8ss5F9HY+dq30b#@yVT4@bxBcC>FvVi8MH=%*o0 zemxp7zr6CKdlRVxCl3%Bk@l?sfwQ5GpsBI(s~2~I_QDx{WklXNP8kBEg)EjzG3U#C z_bzJ+bl#^ML$9$iH?V}`1STX zz~!Gme{O*A`OSkr_%&L49`~fTx7VUxURCU_HQ!W;|0)_}ixyL1$Y|C;3yFcWfoz1&954K`)L#JhUU3cRD~Xsy?rOPVn~ zqvf*9Fn4#drNP1R@$vm2p@jpS6RL^C!fgQVNRGmH>g}^`_43_A3PvLtGq)ADzJ?`* zz=IeCv~#)QSh-i@4fbF{!ovFGC_H;7}lafoaIH?Z3A{Voi!77l!QU>ek=*;+a-JI}e>blU= z2(b^iWVT1N5F$my#gix&jSLNYF)4>)65HX@4qr={^7*kTA3Mx_7SEfo@D@9SI9Lhan0pn}wD23p>c>TcS28=_!gLx`zob zSX*0LhEvGtYfPeYs~3cahsS0wVqicu^mTI>Lq}z0rPZD?DJf}ULC}ZTLFKEo1qsDU zE4)uT+1RlBGeZ*-j%ybcI2>yA@^vh?_B^W$I2*?Z*8!U;pW?Gd<ODY=0%t}ep_D~C@t{1})51!Df3s&4$c4XAN~i0-O&T5>BU*peSq36MjIbi)0~XV2HeyREZSUaVAjN5~ z;LD+H)wd${3`fh2j7;0^Qx%GnAAdh`g?aK$HKxw*_JtUyCmLwoy~hw2gw}+ak z7fqt}jWgv$e3zSIY%GJL_F5nG+TDF}{-*0E-u%sHA^Lm!M01+NqOra{Q=7&UAWGr% z`O%Vb9tZ8Op$pxeemA;IB(d|QrQi+~FTN+@wV+8q=ffS-v&?*O9e4~Wh`D=n8>5x0&v#O9zy#N_*w}AbSq=im^vC3#G5jGw3C9(wB27eviknA;o&3yYBQAg#*+n~t1YADXByQlt5!t4;S$#i%)h3SmGnZ|u zpMgsPe?INEttWqX#%I6DokQPbHWIYxGq`kfaTpKbt*%Su;L-m4F0}@<1&;V}@!Rzh zd3$?%0l;A))z$cdR0Npm>FMO^9T+d-a_jO621_3yt_E3G21z_aJL>NqGPoS;%~6Q> zvntx8%8O4HS=mQaPeg9pQ>MRv|NaorxX>3doN9oXxYvHh_a#5~&~f3Kt5?pQjjIka z86zMKMM0`K9%oPvKAYD$QAnjn%>6j&1j9pXFI=8H#HzTYJX@wAApBBe-QFz4%xwQcJ$Op! z>E)5+U@Bw0hDnxj@RX(!|Bq-tw4b8s{r&w*S4UrnIn&y|M{Eh+ zl+c>yVsCMgT*er&M_3Ls;}L%DSHM(p`<%-7Q{rxGG@p|ALjpba2sn(0Oq-ODba{KT ze5$mcs)N!k51?+WJhPYp(`2i3FNfRNE&r!aU(rv$<`b>ZCLxMFx!We^u%Acy@To7Z z_8CaEybc3sXUGmi=pETlb2QW~rostknRYb5Vf{r?vR+slYiVf(0Gt3;j2P8sRTf%E z(1TdSsOe^D1fiYsOIACbvPo@>^Ryn4W&0Pu4cwo)m5}LFHP8|<%H0+s6*fwHL*j!cEu74`R6dMKO23)UA<# zqz|?y%PcGU^Z?vDIQmxhgCFUKMzew@-PF#=NDMngJZS)Wvy2pV;%A_qme9IgA~g6A z70N5aLjdc(I~O;6&jJ|NKf(Zjuin-?vV6WH$3m4&JTx|zg2BP2OgS609I{2rC!F6( zTlQ({*Nsqyfc+=QgvLF5O8&hZUrw8jU?0&?A2|hm!SPwIj+Pe=oM5xGUIP1HAZXl& z&)WR5zfrwTnZ-AD!2k{LL-TkINULA}`N~^+rXoY@8V);U_69*geCo%`e>XE} zd12_YbfmV+)k{4wFH!9&l+HCS0COzx{!HQHo9h@FX-6 z2ik94tnEzl(#Ar1DjL5tnC!4Wm0tSXmOH2tASlJW<{SO_E?mv$Cbqss*{#FtB#}0F ztblf}D1;oc&4(ok?t16x=X&^06`Zd@$1!XSXh|6u!0({X~{U8JF z%Mv`=)w5C3Z}7i34jwsz2#UeSY-BK$OHWHr)LJ&r;fAh3+wL@+L20+#@`~uOv9ae{ z_w^6&*DN2C(^3Li>H@pm37qhNeBJoK0>cRMVkLfjfx{1#{YWD2O@IWx;k9$J@Lv-&mB zcO?MSt93P1p`}(AyePirUB`|zc6UYlVCOKLgZZV3lBSIajvd5b4DaP>$RwtdsMFbH z{pu=mF9T3=UAdwS*R4Ub?MeWhx1o+}3t|14OnA##6&g7euXI8th0SPp+BenB?kzg!_z)-amc;TAZwU=r;Vc z>I5)m@;VHr9PbMGxw)?lYkv&dtC>6ZOY?B_*a_Da8cU;-Bd{L6X49fyI`Bsn#_$~Z zZo{XZ0f%At;)BozkoqzjWIe?C{AsoMu{OfR*ONdSL_4G|;G{bB#?}-~H=YL-d$XSQ ztC8{ftxq)_rWGJ5OSj|nQ^`38y{yN?*At%)L_VbY+=<=nePKHWTn!gn*D${Hj$W$p zpa$?w!VHhbWImtBw0O2gMxszCa|Fhxbgq`V3FioDE@~B2QYndJvYwt^F?M3?B0QFh zE$+pz`P$rXTxTdU2D$nCCz8yQm;i)7q^#$pY~t3c1%4ZMd^IfgR7UP)W1{jimf5v`|9d%h2k(90Y2tt*)ghZdLl9Uz_$pH*v}yxf7=&{P+*#*p8*?2p>YN|z+7_6Pt@#kp{83?LKD8!MwO)5Nq= zN%mK@ix;kAkXrAjw6&UDT=dMS);>XYG{aW9+6_V!=$h0x(XE?*oU75tMe8cG`0j(n zFf5~VCYz~Uf8n^3Wqk!0U~q^K4`OrpFYM0#m!LGca^UCXy}6uJW7H{m&o`3)ts(KF zCYlsm+pYSp^sA_lr6xP2uYOP7Brgzp_oMOmWGWSid7W$w>j9SJ!Dp~!@M-m63VnO= z$!6e9fKV0e6ME-2tINqUwL^6Jwz9Cgxxb9)OWdDTDN&Y_5lN4VQu5AUfzh!>I1@+B ztMe5dPIVZ=&nBTGvjKdbJ~S!F<|A}#(n2qP(-phwNObJ6K|>%$9a%bn^D@9W)8ZZD z3geolG(tP?y8ucTmUC^A7Ok074@Aj;ldMwmz> z2$Ojsqj}bw!>X!+5rt0s_GYa6sb$vCa_O*{_m2;u37E`S z6XF=EhD!h*Fh2uO=W{q}GMq=1vJdISKzmIj1+oq<)vCipVt7W2(Zq=*9lkVhsRb|2 zJ;g3~JAwbCK;fO6g{S9U^xvT3`4XWy*F^P@UFp zEtxRg7Cu|r%`-z_+2Lz*96qA`t(YJ1Rff41ZE^UesO*>sK=^4{3OR`OVMtF@A{@d90%_%U2D@Yx9o(13Q zJGocI;fK5;8{F1tNfL$JSWX8|ak`b2eB1@w4ZuyDPUjmnEb-g42YSu51^n{vzcdht zu>OdFk{rAVInnJ4 z_GIe_q5u=0qQd~l{~f&41oN~9ahPg7=RUFH^MZTK2vIGHdm^Yp(0=NV(k~wP@g6|h z8%kZ%DH{N8vu=emYiGZXmSWV2W%IEXtu`XccLhT%igLQtmH?^I25Ro-dWT>6;XBh8ao-e?NZ-3W5hy3(j=E+|s8RogXs`Qeqx|g(= zcop}$Ywk*qb{hy>Ugpb%hni?>kMyObrFkZ+W=`u1-afH?*L(ThL@!W*G_7&zOzvR#PTJPzhR+B}>1l>I{b_k+Ej zh9~0(cP-s_UbAl$qP>5;)Tv3x=~hLIbAF77!NE*UPBu-%{=g=#@Gzv$v`8l8(~H{$ zLko-fM)w&WP)yj#FlRJy?`H^It(rzA81D3d6b;6974!;#42TE__B5E{vK2f09Mmqr zAlZOjV+IYDZf4&+v^KD8AZyf8A8zx>FVEI+B6tsL;rZs%a8tDqe}?lnpZ&JZts7k@ zKb)PN*?WqMZ`l>ghMgllYCD@(lLWe2BN$%CzrH}K9t=>-LyUt50x%cla9iKYt%8iq-sP_&Uo_-H#|{i@ zmmna@V81T!8j8NH4z(?E!^`O<-*1q5;NshE46wqB3MW(B2RMNMh1FjKjD+9()5cBl zv+H*RJz_X-#PHTUh1K@GXJ_CyGARCDie%8)BRqeGQ2IoVfN}Xhw-bOt@nL)VS>>aN z$5U$ehPg$}-Zb)Gvmd`%*E{t4WbjgP*Cjtda)6N^HF4D1AShkU7mBGh6&-CUQA>g7 z1XNI_wY(=q!y{v341Z}bku1%MbId9TMbh68Y0KBokGR(A^T`Czmz54!`-|5Dw$zWt z5>xLg8ib5td(I--NG_EP2VgV*jZ zR^$T#$kH7n2Ww#i5t18V(5#K+dPq` zTlv{&Mc%iEp3l|R&LaLo{cdZ0iQx%Bdb_|SuD_@vaiGX9 zbk0N+Vzv?|z=;wxK~-^4HV}}-I_l31+Qbe=flPn&C@!ip-+aP?K4O{YvNMloe+i=r z)gi}A0QW)OVMM&gEV9{01@V9vi$dXQHjAW}in+0wU2T}tf3H+7k4FZXNM6ccQ|Ypx z3P8F^=qL!$ql5ED!iWXRVCKtbx*bi$nU3_}11_g}DKK((gNlRThzPyd58P<%Y@s4Y sJ+zMrTq2eG8B2xIerkSi8vzFK3GWrtZam`sd#PMQMOV2-(Jt!$09jQ?=>Px# diff --git a/res/drawable-xxhdpi/ic_mp_full_screen_light.png b/res/drawable-xxhdpi/ic_mp_full_screen_light.png deleted file mode 100644 index 483a1a3d08f131c5ffac85ee35b45c9bfcb64c45..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2823 zcmbVOdpwl+8lRO=u8qrL&rZ`cGMM{?naN$k|lPsOlX3zeypZ(*!@8^Agm-qR8pXc{H-{)P?3ZNoBgR71{Dcu zu>B-2te4Cc2otg5<$z!OUalZMLO>D1oSmSKDyo`53@CX}l{iwOpsHxFkGxd%e0>`O zgMKtoM$llt2j%a@fx1fN0F;C#p#)ew4(dQb<48mb4u^!|u{Z(-hsEG2C>)MTbfDtw zp`R|8+M8S$LiJ_3fAXbX(O_XprHqQf#Ky*=V+m-fJQRbYP$(EI9)riD)D|d3oJ7e} zp(Ki(8w^Z9A&`q?N|96oU1#L+rO`?nOdaX(M-a=ry#64TC_W`hoidDyC&S>-Sd3V_ zey)$!3Z*abzcBvNTEUHz0T^FEA&r&`)b$A2xdB$^?(ZF~8>*wBddo%XqVOV_QbDvB zkSN(q8ch8KEffijl#bWs=o&X@Ch!4}=SHBoyAfT9Y6=3GNG9RQ8(6kPq2x&fz=m&;+V>|c>2I-AS2@5_O66Rs zG;%`)oG__UstA+Hpsub^gcnaBlB}<7)@$_pk(q#86axs|PX8o`@@fB~ z{$=iF-3LK$H@^q5W>lc_ATt)Ub~=g3~v7n*@>L9+jFKa=Mf?Vk4zy2x~hPj~N!f%-;g+f2M_Z2KmwtmUcuhKb)D z+t%-7Vcc4`RH|8*&#}-hW7yDr{J`xkDo!9GH6kh;0s%`6e{?p`8g}Y1Ud=XdysUAO z0Jf_iNMy%EM=uht_LR9zetA-KC~dZI#wozuKe^=40q#oGWerFf!|I<^QA1(GUwfx> zk7&z!`o^3BLKw{hWW6FNS*yxc_tDT$(B_wKvnxALaJ^8Af9C=BZ^^pqGxduhjin|L z_GhcmJIl2#WW~tJ3{~xvksUp~tU&yvC?i2To$}PW>)Tr?c>&t<)(HK;<`K-6Tml39 z)Y@+x9$$l?XL|ZnTnXo->!A8%#hy9m5XNP3?JX@W#n#r=hdj#rT}C*oc!W#FCDDNq zqF3eDmC*)f70qiZEYt;RF>SD<6qMnErg^z z^hi5yggM;#bw@@zyn5C#{L4i+`1dYZ^VY}&f69NvQDmB)H(e^;W*S#S{Fyt0 z@PM*di@rw@tJ5bZ+K+_J96kYVn>@q%SM-qS00~3S)>VyrvhGCKbWV#UlC=ks_Mw;W zI#iq+^hfwqaBmO@gx3=j6K`Bf4@1tk&P$}yWJhP`4?o`zzmM#~7xGJjpJS7_*9H%6 z%lr@rEvni6twYyCv*!m@S2da-$X8;c6M>s>J0{;@3aA$yQHsQbg0ZvZ~83*xNm_Ab1iqG3YL4F48Dpqsf)_Fvv>d_I$#v=B8bx- zSo4VWLgBJ{YOJ*ki9}9o1Y5zMPUjW8eo(LA6x{773Cx+}@vDs?`Q)&{?CeSBl}5HA zd)HJ0`U)8_Uw7Ov@9o8!8aVgvum0`tDq(o#Z~sud2j9+ZTw`w`{r17bNnpZ(R_l<0 zsRIZd+bZak+fu4Y4qIXwB6mx)ha{Nl?!r{CsTqQMI~_V#u^ zd{L(<1wIbGER*y`1~U4)ySpu!eU4b}_{;pD?7^C)NMG4Uv!Au3*;RXKM_99-Dp$wju8&7v z%I8l;Cniei?g|O(JVaA9Ud7OqpXxUurCS1nvn>JaV{pZ91SQ+_O!sTRR)(bIxxq zN!OBF_0p_F-@!7D8D2{**?UQlt#>JD^yZ+qQ5(CWCtRm(f0NO%*ij)nrGC$D?U8o7 zd_8j}U&GXU=rlk3>8Tf?FIzT&S(zRJgKeyL9={q|LOS5yP4R)@RB(+Z%4d4CM(^|pv!W?C+#L1svnQe>wF{{!HW5gne0syV8q+U;q3|$kqi72K>Whnx|&V uZP>!>&RwFWhC?$5Gfh)XuYonwZ$Q=7Wxl_pclNITnzG&YGOHQir2H4xf|FhV diff --git a/res/drawable-xxhdpi/ic_mp_video_large_light.png b/res/drawable-xxhdpi/ic_mp_video_large_light.png deleted file mode 100644 index 0a34b5edaaeea58ae18338ad4b5706ef189fe338..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3596 zcmcInc{o&iANSri=~8Y=MH*9CV$5oou@y6FVr+v%a>qE#V3uZv84@+>iWZS_D|97= zLZ$3-UDwS}_9gpZB1=OF$)!sj&-=&wyzhCQ^IJaW^Id-5tz@Yg?y&) z1I(a*I^{-ofLO7)0L0M15Jp3wkPsuB0n!kILn8GdCMo2yzof(wQ=X3CIxIiE<5TFg%Tt7Gxhr_`UC^!lQ0~s)02#Ze@!dSeW zUo{W_9*xW3@EL3tWLYECn;pbAgMyy^c?2ehO#VwTi}xi^V94M?DhG}5kl2|-Gl|=)-+Gc>ZzsMr~qb%Nv3sCuNt}~k* z__Ycg=xjclM`v>&R#p%lGL^<)E$?-gbM)tt2>_Q74ER`c*-XgiAmbVT;D9#7wnUv-RgOAatd$~VV|?Gb38eMuF|pnSHVmoiFo{=oqL>RZHV{dvGq>r zPK|o*N)e~^(yglwi1uBu@ZXmG(EH)7qhl_FgOu!-m3p06|Ee9YE}nJIbVQcD(e;i# zwFKykh;SsbV_at2=h64N_WBV_$%w2j%zQGbXIyLW>1@Zu{8J;g@AtKjOy%sVY&OY> zRzQsZ9==_FM*r`?-kJ~SB3>mTbn#)nVSI{JpBt$vjftM$k*6N=v3agaif+H`NSOo z>FnyV$jr=~yPjdQRYkOizJwi3XmcCS9 z#2Yp!4W0JCC*0=JXbKxS=oI7qzDdo)Y_Tqhb}fb2*c|`MSv*b4F6l#n)Gm2pVIfo7 zK4szB=%&TUmNsjyN&e%4&ryvCKc8QIe)ji{_*=W6g|A)9FJ2u1em|?+*8mmPyvcqFG1FRI zmxMzzX2v@rHhE>_W)!^2{N>edQGnxKX9v06%{GWFVHEL@y&>?LEGsT9)}0!zw|Fx( zXU$+gSv3o|PX_+HX4XGM{q{9C{h{X7*LwLiCq`Q`+0p8s>Mdn)Bz8RoZKPbVE+y>b znx^x>;h*jfH%cB?pZNHGWMtiD{Y%{PMvrH4ij5{7(84&yG^VP&`7eMHiCg=aR^5^3 zS8m4cHPxM+giqF-lfZ41BE9N$#hzYXpOSIBp#{hDi9bzW4)!J92<2rr^-m+nyuD^V zZ0=2qh^6F1mrU+;CY;({8q*z;Xy6&@*LMGK#0Ktg%PpgoQQ@zihRwZ8x`y@(-zBZ9 ze^MVQnLNYwD&oHC*^2fJ(}|548t}8^yr#&;+H>n~V40o$N3N(tyEI1Q_ZX`bFN6cZs>@h9KZRnO4FJ9dHs#V@BC@ddAQApi!<-_%bQgf!@}TYPG*O zgs-Ud#|!RG<8NOZrTFacR<3#0)X*^5@pkapn6kQhbp7zT#Ds(}{aqGO5{dD5r%sk` zI8kzG;d%5%ZM)D%(wSa)r4f~dXdULc%`m*np+mFUMx;7-mEm<76jAMb`@Aar=z?l9 zhGuhBUw4}G+X`dCq>^K?OKi^*{zwz$-Ox{gMq_f zW;@l565(D)Wi9AYbN2EGd?}yQ!##J#s0oG%L$LnX+p?M{uxr_*BXTHKU{PxVbyz8{yKa zxdnNpqO+}sR1&*j9{EQ$$93P&%^IS;RI z5HmC1=n_I88J;R3S`EXuOndYE>J{U%!Ww#ddd^5BDq@oaX)MrDB4DR&;V(HI=#7?0 zc8R+{aAuCPgw8u)L`LA9#rU_0c) zjoAG&Z@OkP?wi*4$X5r2ccce{0rF{G9E?r!`=t3e^^qb~WC;eUVO-!DVgoIfCPlP${miu1n&iB`@j`Xr@ZNFPoG`FLw zs;c}4Zo!_+5*Ik*PWO-S{oL4(T|MqKYCA>cXIGnV`q%B}UYmi_`a~j;7Cg~g#%a1u zqO4O;ur}!*kKb4O))w8{*!cE#ditxrATY-!!Q+Lgt53$ZxYkj-5-&WsucGF;mgR-0 z9u7HnkCS^fxHI!^ck+mD#dMC{ojsKauPa-p!s_!(rawBTZ`0`sfNybebk5lra?2^+ zl`JEu6f`wR4stxXX^HtE<|e*?!=pfwf@AfFSJ)KVMHx_z$6Sbv=bB5yaWP|& zCX1Y+fcE(hc@HXVnde$^Z^n*UmV|41Sg)F(ama&vJ0*0@Z{KO)w~>gl>ZC+YUg_`Q k+x2Jke*;Ktm$ZMCQV&CyeK#`=Xm%WD~3QW#e;esyhf^k(Fnu_PmKZ;#6W5^ABqyo+%e;|^%%5R z=#JS&4I~CCe4%JDYp)X8x;Ka;*c&6D3o)J^=nWbMOdy5SAX+1p$W#oCJLUr~177R5 z2^jPT6?Kd|=A%>Lfx&2Bxe`KC@l>3ENCMDqbUZ+%&;h^&O(Fth0zf2?=r{miP}~@R zD|&8VU~5XDh!Mi{pR)x^?wDw`TEQR?;^X7-@npPQ8ASl-bUJ}ZB9KTpSOcd@kf}ip zPNrHt&%lIK0;O1?7RzO5JtN4M$Ew{iu%{nGkSYQLKM~7RbBTgeM$mu?0)QtHq*8rc zAGB5K5a>T{d{$e^az!YQ}g+?XO=CN#dHZPmE+XDbdR4 zmA6rG7H8Vl#mQOX7JCQh@_tRmW}h_EbzGZ*yaQzxzn*kNe;amjuqJCY)n|lBS{WulV<36 zhi9tg)k7tY_rsPhKK-;>vLXlJ?>FQ-%%qLYrXPI5@gn4h{c#{b^S}|+Xma&j^|Z}Ibq3DSBhmAW8Nlj$zG zV1PFF7bJFGx9+bxc8g~fO8ag_mz8PSBlYu~s)~;M{QS=CYZ5P1_oFnIo^zhOe)Gmi zb-AbT@%U^iP`#70^r=O!UFu?^UWQA-NjvYPyTg4<%wb(!UELJV=8mLZXCO9zu}q1K+MHbG?%uK=fm5a(> z@&?%07VW(8vhsfN4YLx{fxbSTIsf*Qd3nPa`#b~tQsnhUghc(a4KYy1DF03H zjdgQ4mUlnT~0-+Dv1G4G@=*@YCL#Kc;K7H)?BZO)}`?k9bJ=z9TPQxh=gn zg6dOab6bp(eh(t&h7uY(wCU-{n3$N%qN1XU1;^U<`S7lQwf7ntlCE98Y&SkOc3{|b zSG>lji+DEW=Be)P?nq=|?Zly{_m~|P!HuUmyI1&E*0vSh&H|jypF8)8eyQ^q*Uoe> z2En3CR_dG*dkwme$jWltM6)A9L-y01UW*b8mwlaFl>B1D0Kct4oP~NF`D}5>LzDKF zmX>o1?s|!$qUuwgDY(drVzw>+T$dY{%l(^mPRh$GyP5}nzm3IWSB#E~I5?Kr8axMI zRRud0>JA-hGc0}Vh<3Qnt7aK=6k4I(m#Bd+T zm{gZS3#;SU#p?>&0~9hoyPU_iScOf@j&iBW4WN_5Zb~lLMTq2mctZl`a_+Qkl zJNiEFg0q12eF{5Fr_;GET)6P(UgPo|_(&HQm+9o5())m=4=ZhkYh93k@^rRN_b8zJ zj&Zl#Hl@P+&j{Vm{ns23Ys>$~;Roto?ln8~-$TfBU(p|`+ z4QgvFU$&NLXZ$gzE3W!9(~r%< z;D1Vbnai(#(OITKj=Ey09SI9%vPwW2ogLTSea$}MKB4d2| UsR`#vy8f4i?H9y6?IkU;qFB diff --git a/res/drawable-xxxhdpi/ic_camera_front_light.png b/res/drawable-xxxhdpi/ic_camera_front_light.png deleted file mode 100644 index 3af175b86f688e3e286a825666e87c83f50ba4a5..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1878 zcmbVNX;2eq7~Uu%rl6oGDvqw9fT+nHxg!BW5+s8h6A)sZ7Bk{JT zd(o%T0RZq)M9Y-an&x^v+^KI*d9Hw3rjT+KnMml!T+ECE5-p*|L4^TJ$CWsy&0E@x zhXTMzi#|z3s$v(2G=zbLxiBoN!9=kEAT-=+!Zb^85>(^qdZU=}>_h_t)N93z6mBdW zYm(v_`sjQ!o|qq(q{&~R5o#IXVPL3LL@6-fBnDaySw@S6ao1{7Q*ES5o9ii!U!8eUIoz0(uk>vY*NgiJRJuh?iAN)(kc$cmXDgatzegDb9gXbY*tKe+Kxv?VFe zghNW)LS&mYR6TS7!(=LVpEl$IQr?K-&3dXRSeA^?WE*fJsgQ{o)D26k*NV8Xh6BT} znu%#}4wIv1<4l176)-s{hs%RCe4Q3ShxL4nk4Et&a=t()h>}PVM9z^QQh5}gE#wQh zGJ%}W9_A{H77{aR@L}6}%JvXf@JX&nYQ`~=Feed2)^HajWDq1_$skOiR0__D#WZ@O zOAc|>XfR|MZr11E+9)$&0N*89r2mKmd=AV;wHTMFV{=g|WE5tqxmtwD<6%6UtHJrG zPQV!AYyWTZAj&Do)hYk!oZ%y?9bMb^b3h62CkZ!F<71}g#IycoJ2ifJ3YjFydi_ad zL&*e*@A=f8yj4}3F7}*Btf{GK|4tRsbA_I=b|*Ml(sA_*=Y+Wox=(R*x9Cv7&MSrL znP!#8_iFly7oj!vRnO-=S<5JNUaP#BM>M1k@MdnSTyC$pS;1%j-jW7yY~0`EW%b#< zvM#3UQIoj-v%2oineoYEJko-@ilKGJ<9Gb~_5=Z}fwt~ZwL4P!9(sH4FQj*Ie3w1% zZ0~3v@yKyb)e(8Mt=OAcH8bbRrAs5OcE66IEh`>(W^7uFB!wO8m(9xoc71J{erv7Z z!sA`)vU|-sdVPeSlZYJk=W3-}1mH{a2IPR-#^W1Kk8`dqXf60{MRI2jBKJI#nYdwO zPV}bIlc7BY`WOAXT2ey-06*u0zFMTg9<;(f2VmA!otWTuJ4E%yvA(XVsqaR}l3SdY z1wY!}l7VOC^TYW&XpL&g`SG^STfXNn;>%mR1-6m3U4I9jtv!WI-|q%T-YnV>2pm}x zlDV`5Xn$O}2Htmly!FfAM4tun-aRw=LIy6EbHgu)E~0g|75CaNr`&sH?hijzd9Z9V z+h3%3c|J7#7@(8Yxwo{omBNFOMEFD~s(1MR>>oCEM&Y>pXDf3RNBt9>d;8}(r@ZNY z?fzA1eLq7c^uFP4x3x6}2Ucu3vU^rZ%>7d~`)TNoecG6DqhYAIXn#rH_K1m3=QvfH z0_WI0z|tFk<&1W0yvu&**HuWw96eYI1M;xqo2$BtQkEZT`(f_F6&Dw#u09>~v?P

O1(;jyT)_aTAa~6(GGv{*eh>LOrXhX|@}m zck(7X!B-nz(l`$&0gk zt%tj43j$IaU-e}p^}X_#**#O2#WUW%LRx`eBQKS$XOEU>cpbiOhaP6Iy*X{8%Wd9j zdmotYf$l8Xnw?iKx~`0=K?CY{y)quC_Hyumpa-iCc!tro@CM=nPE(-9H4{b2u>KtWN<)mE}0I1;WQAS(K%EQiwxJ$K4EGbZ?0Fmoeuo@K#cogg= zOrcb8nSh)D0Dz1N$zcYSAq$78Y&M-uWzZQ+79fvERebKA7SNa-+t-?jQG?^uo`P0E!HQ# zKejE#;T#tT1^guAt>-Iai+%V$M}E|wGXm@V<1*Nxex3;l{3}i;j+fN>4?ei*V0Ni= z^BdUhCr)Z@DDhihtEQ@pn9719?J$s8%iH$W)X$PA!YTj84Pi{Rqn2XFAJi7V> zXUC2mX3rq6x%hyyM4$`DGTDe$yc^3zl3MYZxDXv_KR(PUX#EcS202R>YLUJ=0t7=1!^@E< z3Zb=qjkh^0`o7maMUG>T>4Htwv)Sq-(CK+Q~7YW>0yj*+x`MqQmP(T;~#(T9(=91J1+3os#lu|ME#|ot#WOdarBhuQNt{ zzHxdn;mZlRJCl)8+lW*rnFUEm#xE*5{^Q##2)M@AYicyCk$~g)?k?A>j`z}krfoRm zThVq|Y#7BoT7N&<TdEM?ak-cPokj16q+W00=~KW27$<{Sv!Cw`G@-BNh5%qzEj&ZTFTz0#1| z$^4C_#Y;IgMa<&WCr2ak?g`tkjUH>O|5{v7A-KEKa^&oi;GB^8mj(m}miUWW`&(j1 z=dH*-JF{T8v+`~*$*J?N^{#1$2J*x!SgpZ=X+*J9RaBKZrz^a*ake-@lu$3Jr#>s% z{iVwfx(5SWgMJAwyUe{)xv@JW8-DA7{wb?@sY9PDXK26swu)oW#KynOi1e6T=$N@8 z*=#VuXl4%8Be)rIxS>nf*oz8k_atiUf z*ER*5{P^_L{)Zh|)c`9ky=rf#_ph8kM@pvOY9;w|1zEAV68)z3>u`IO!E~{Pclb%s z;Nuw&vU?SEJks#ZnjRlp(O{h^nNe0ydP*{Pbw%Zd`EM`lt9yLT@74@0_~~6cvv)}) ziKw6tpQ6MOvEgCGuQFbs@7*-Sbu(V!)@*VKBvvQ_C58Le=H}J^QWKo6*>LW+xb=CBY`?nqDEONP^IS(nTkSb@D*mO7z zRg5fvNTLq2OK8pwS7t!S@DF6xQC&#ip$M*oF^rJN2}}l#J&@py zGnxpUCT+}@a8f}Q6*fz*h>G8rkOr4%j4 zO$ulXZ~U+1Awp1yuT%cjIpbGCJNm@eb08dEPZDhr#)lK;By=Kfm=b2+FscYbH2RZv~}6F2Tz`l)E=JNu!qZS zS~~f2)73ruzbToyv3^RT$6Xh0NIUXfUdW-{JM(fJx&GA~US=!{ncIJin>8V2ma}lO z^KA(8Ees{;d)3K4z12vny|j@*rGEJ@6~bj;^tgc%=Smrh;Ex~q-QqJ z+OqdS+>g!uWtwNQ-a`rN+9Q`|?&z1V3W9;GCjABf%ws=gU;8VRnt%M<#nm0&z)vDu z;sSd6`#OTJx2%0~Kcyvi-QiuWv41?i+tAoy{J9Zw!)LogJk$DKT#na9d4}h>Vm+6E z)zrxzj(7K+F|7LVYfns9rE{QLHu`h?9~MPmPg_fqpPestEqrvaKFr&__<{Fq-~Dq9 zF7H)u{D87#IDf(Kb7S&JJg;fxn)c~|`Mq@!2J508X?Bd?i*2Xhn{`B+wBAxvS~Wy% zscSC{s38tGcI7=*oIB(n-U{jctu8~@iTJ8iASXE6?*^!!X#K3`_S*9=!~1Vee^}k1 z0YI@o&=e6H)-w{1ho0n7?n^^HrOc>lbfLZ0ehnxBb;~c)c`kY p+k;QQ?SfF>sh`p+bGTK06M+*qFH}zHI@RT)B|SAmvnRz+^*16#YB2x+ diff --git a/res/drawable-xxxhdpi/ic_checkbox_outline_light.png b/res/drawable-xxxhdpi/ic_checkbox_outline_light.png deleted file mode 100644 index eb0a80331100b9c03e95f0a44a7178a531511801..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1695 zcmbVNYfuwc6yC((1S*!7GGK+B1;hu*?&d`{fk%y>ST zN#cf8UtA>P0APyEn5AQM+D{Y&WftNd8=>87Wzhf-mteQzgo$K8J(+K`#6yqTJ0Z|$ zh=-O-wTRZ5OcoeZOKEaxX?hk>Y9cTLl&}blvnyBvGs)nf-CSg`DeUpkIIn_Td$wT+ z95-Q1@zCp_blMCsnW9NhDwGNcM1+E|m=KjpFcggeMF=W}Q3MuY0u)t9Vil+ioOmGC z8*Ru_WU5joe6dU=33AP@25tCpxckg%PF=V4rETN5TQSc%aMHHJ!3!_oRL+pvrU^FPC z2q8fbL@&SzQX-J(#iT$k7s&+@kwhv(h}b*>Dw^bZ7hb87s#C=BWRWyhf}(1PQX)s> zSV|J6lqaD|88V60SZoY#A;?MJM%MQPR{376BAF&}hN815s%WwcG72b$vK3HPFgY2V ztHlYU#iP&j)adn(Z!_ zvh4js^-I!!L4vBpGg^x7haSBfWLx+70n=yMz?N-cfc8tBjX%h{7qFr@N9TfWG=6&U zarBml7gxihu-9|MkNS zdz-?tpI&;9QSCUl$(+#Ef96Hn!aX^Ts!rd=z5Hyatg9xlW$;AAf;+sT6Wk6*qj5!j zTCr^ZH?AU1#vE$5j`1bY-c+M)p@s z=ZM^`k>I{*BLh$TLEhNvOP7N<GauvK}W*OQ{1-Hxkm#A3!kgK3O=fA z3RL9Hn%(3~KeOwylIX2C+x$EaPHNuQmvz?TyjJ(A57uwb4ejvaJklT^-rnHo zi;UrR^zPYDl#ND~wg(N@eGAQ*-HME;)74+|62m!?PdUsQ=BgZPdFh|`rv*+H8L;N#N8z!5_9eK60ucdtZgTdS98WujRC>!>!uGxHb zZR6Oh?DLmjx}Ced*M>xP_hM5j|H|*y$k!kAsXVe3KiTTrH*#}#{WpGz_UKLjam)5t d%l2r_RG|Jq)b9&xq#VyLL!(YtH7RqQ{{T67aZ>;Q diff --git a/res/drawable-xxxhdpi/ic_image_light.png b/res/drawable-xxxhdpi/ic_image_light.png deleted file mode 100644 index 0d229e265f79afe184d605d0b548d19d6a664f02..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1945 zcmbVNYfuws6irG=6bMnIicE>yz-SOR*-bVi*&#wgP$Qx-M8&p>n`9vYl1(-%B(YWt zD7F=~0wd^%Fji4~^B4scK@=aIY6irywiYb))v8e1j<(apU-sr+XI~Ox|3R!pu*c4J5cx+JOzHFu-lgv%65YlK;{#O3(e< zB0liagvwR&Uy;g4(*el@i33uh6f{9%7?3H1uoO|i@GL+K!4eS+iNp#JhEYU@!tub! z!>6N><}6gNnKKfLUMcw&igKVLQ9(h0us|Xt$ZQd;P$)!@SR@vMv<2w$*eT2n+FjA3 z9yGYiL|Pq`m9PVTkC>5IO)2>_(^prpInvTzJGQ$<5=Ey>0~hIW7qOZ&(e=oR9tG36`|m)0Lz)e`h_up0!SXbOX|)ZvQ(BFZPoD_Q zRx>JvOb7%)Mi4XM2#6RZI4GBk#xzPD{FAlnva|3kc16*nPk*D1_2C_NK8iD2%2Dt7(^6e43x{{MoFYkA7q2@Bx6|!I(tVP#WnTo{erL2A zmBD@c@oudpTot}CH)Q51Kv22&z>JHWlnb1gP4~Y`(GkKLm53kdP;pg(Drd)9RVmx4 za4)_PIPqi+Jaaw*Xf^}P#f!C@Vdo!vR(v#7vb$W_!dn~hMr2aJI8F_-nB`-r11yYMCbIq(-mbAE-e5GVA62Hl=<-^)NW5i8&zwBK zQm?7482?lo<#Qeg%lWi>J<|tTSY?+JpC$!UgPURkql`n1f};Gny#f1^n#oxM)hTsH zOMczICMmFJPg&RsZfRYOxa)Ssut2XX$=^lQMg2hBW_+u;HYxpDaZ9+$!r%bNde($7 zNck9%N%Zli@}GUp#xuubzA67}Txk(2HoND>sva2UUbfF_SpGxYFU;HLc@w}H6^&)0 z#*RBhS(8(_n;Mye<&DdJzO?S#v;gHYo=gV^dIq>n{JBFLWWI&_fyT#0H=hl85+BAQ z)vf%AP2a_}HDctz;lga)VE!|a&COJ-LUmgf{tf7GR8mxely zM)~5oeLGq@QY~hE`nH56Vz#$tU3G|ZSfTdR_RK$hY2dy}wtZ(|KK^E=n`IkvR%iP3 z(>kWu)ep8;7RKCJQ1an}=a0jhE_b%X=Suz*hj=-hMe~DldZQ~?JFb_w53r@ayxrFd zkH6WvtvtNxL`i>eU(H3d`Fjbud|byAp?cZgPz@2`h(h0Qe-?C*y{qQrJ1f7A-0_tw zFQ+~G-Ykx6jlAm!?{d#07boP?i^b8-Bh291INsJPOSo-6;>FXGQ^z+L(Kx%bziDg# z>E5E%8w>k-+n2tjTX(pgS&CXdymFYW7hI2MTR9cT+nUOGc%$>e&Iw1K2@Lm6Y<#}& z7`b%8j9V)M%um)HYA8F{dbhk%+)f46_kuTdAvAS@kG2(-}!A&>2JW1XC zZNRP?TAi=ev7x)`cuRNK24>pk#<;<~Q2*?K$5)>SLgcll-u^7uGxLEVoRzTnS}42w zEY>#cosfxzg%;HQcJZV+mb-Fxe2^7kj-58bhFAotaD)15&_sTz8<;|FhJlEYuuTuPXc- Dv*XpH diff --git a/res/drawable-xxxhdpi/ic_mp_audio_mic.png b/res/drawable-xxxhdpi/ic_mp_audio_mic.png deleted file mode 100644 index 9f51c4b914811e56d174056cc43029c54a99d9b2..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3012 zcmbVOX;f3!77n%|;zQb^s0B2JK}0hW2uT?Pg5(MbA)+8Mg_{5ol3;R?0D=guya+}J zQ&lK}5!3=Q$zVljwOEuvB2*dW6`2GDi@^tiygA3A(s4-l;g!| zGy=Xvfr70Nej)=r!Nq7PY zYYtz25Xxu*Rse}X-oG46DOn+cASjfCMu&%oqry#5d_f=@OC%D}7#tdhLn;|aVFV9i zijX{^{)z`OTL=m`p%919gD-ky`t!peD}*xBKbF7^rBXjS<_VVzr7Rg*#0*7aQ5ZCr zyST0;wh&^l|4idA*+RF7P&S&u7V^UcpmIL~^jD0Pwfp-(i$rBMBo_fkxhc#LG9L`% zvUw0dwn8X1C>DoBGQ)s)3 z$!7Z<$T%|s9*cFr+u_UchET ze1RLEAF|Q~^dLUO7Y6Y|;r8}$V=5Em@D}CWi+l9PlF4iV=LDOzU%=|1BO`nG|}lQ&x4(%9OGl z7l$9ufl~N*lGr@u@ewG`iEDd72@JMw7$DoaiMqzKIZ2xwww?+0#+$&^5ssg!IP8lf z^yR_fJIR|25P2&9@Jq9Ksi~PKRr~V3{#L_&6|sA}$U%&_wm0$HeRnY1uc`g>^Y+b% z>dSd0F}}CyYU8{x#arlU)06@FQ%1B~t7%8?p$@CfVPRpa@K)XWoiKy7`xyHef5M`y zlvcrN?J{>s%fI;Pt*U5~-`@^eyWr^ZqjGQg0l8ZlwG1dbP-a&~FLN&2vn9$$uP->= zBrbA`+qASY*;(qG3?-HJL^kR+>Pw3#4@a$w2Jo(BhjC{4mm4y20y5oy++!i5cv3u_ zv?&2j5=wP(Mx}$dA6gRzXbH1wi*@P!ij5UxubOu5m&@W%>Cgery!TsmSNUoCb24ju z=AGS~OTV8WJ$r>8NY>|g1pv3TIY(;^G=;yU;9o0#8$s}eg)(%)cEJ1mgZORSsQlbu zbwF>c$>@c)N=1du*XLn?4rtfVy`$AJR;H1gJvMLJmtbo&6RRT4z(fv66<=Tago;1D zHW{kR`Z)&9=^M>Rogewp@w80QhT8SME#v$eVC+f+Z90A@!=NZLp7<$6JysSggMEa> zfl^uptF_DARoZ3l5=o;x-9AGbu$?j#Sr`@ApEDRH)!sOmpK!SJ z?CsmJcdMgKjt#cvpG%wdkvD>`_89IOx}1?{IB%KpBr-fi)}(#VJKUn+J=8fwZ+PcU zj%fz;2vMlv-|ua@<&~Q6!aXZ3ZUOY%qhcgQ$@m>10^=)nt(|>fZ_c1hV*Nh1VCLbb zqZ|WjZu*;<(R(+t+S>wcXlmmju5xM2m`BTSxLsZH$?W6v8BXS+y-$nkA7}zvljlI< zb6--7$-wLkZvpQqpPRf6)_nMGEGPY6&EHI;nx?!e&x1ARlkw4qftYaPg1YLSC*XV? z@cPj;UbxeTNsr>p7eNUE9WYCrj8`FQ_@@a z%7a%JIR7CnDQ(=C5nbt2oHjR58uAUR0_=R3Tp_x%w{wtQoGQCHY;K*AWIF0H{fx7A z{-JP2MT(MiM`!?v4;r=>%;{)_3?FG!NPN|NZ54@=k#S)?+E+HuZvzq^O-Tk-ooFs4 z*`3nQ^CyLt3e{JP)WSz&b~;;pFe+L%WauuqJ{O$!mh6>@FsL^i8vHD=BU?p+fI;tpKd$kPVzh29q8DA$Sz0J*(nHU1 zZZ2q)7;4Ph-qDzD_+|5}vKrFk+b_cuRO~~S=Z zP5}+Zr;^398~M!kJ+_uXj~1c;7qnM7o1Q6W1K(UNyRKvX)5B$;sim zOdWqrT-SAK)_vYLkoN9KUU}x3i~sJmj?}-JdcCz7uxcT+wbxe19ji?s7#kP7_nM4n zeJi$UE-tTSiR5Ih{PN4$y)o$=QLNvo@sg*{#+=tP&Q8S1vD3lMbF?TcZPFfKN5iF% z9-kuz)g)=oSl3e%7Ux@81w|8I9L!65x#7{<{1MLqLNm=5h3z2gdujr-1DqW$=4~M- zqpe!Jr2*T=9&cz88%?e^7=#8YY~0$34Ylv{t?%O7-qu%*H#eeMRc!-pMjsc_@S`cA zITdR=#71wn_idPLxZZAf2$fl*OIzIa0?-@0+H#LXC#2KX z7dBR?LPMFBW|eDC>JJG#V_b*g1mT+heJlUZdzXIZYekaQW9b_BZkTkT{I-1q&xx957EYc|%Vf_#Vh004lXxf#Zm z-Mj8RJY4K{G%Z+*-AT}}=V*2$Pg*dZLI4Os!i90{>fLHJ>FYJKp}sU(0p~F ze<^j&$_8Rgq7WeJaCK#Oq^b%;6Af2UN1;_zlpv}|6*Ytk5}}G#R#DMLX=og< zY-tn^Pi*g3kd-}jnXMRijvxgglFCsbk z;16$z3?HoOJ!tp{oFxa*!O839y1Z$t*}dCV-^8=OFOGDLp#7t?kMr0$p&G_ z>9Yq@`pY#G3v7O|FB`rrZH z%hJ+P>T+e&KT z2SXaq1B8zK3Z%!MRDi)?K)xA!NJC_FMx~s-{_dp{p?2M}UG9J^`|eQ$x8%*1?_9C? zg5(#+veGOcD>6t&FCSqv#om+>7Y6!O(}J1G2n5c7aV%qUWrHx$nZ2ugps=ui-hRwe zpsOQWKe7Qd@`W%rSfeo40*G$wuY0|P$K&6Nv0C7wa@J0#tOk3Ocy&O+b{Dd&HP1(r zeuA{DNAhq^Ji;MhhOF@u#zazE`Yj0#`+Aiq(-`P|Q8-IS95WFqwvd^RK5ri_&iN4| zKZ-uRFcSAN_w6g*K7RTBcXx2Y&&aMDaCxujgBM@n(=RtUe*RW{DfiNFWBA*$@^VQg4tD>Q z%nPb*{j(Wa!#K5c3mh)ed**YILb+FGMC*+t;di`&u{+=aAs>@jBL$gkD8oZ_Wo&FL zd~q*}nrlR1fdq5r3P60mfH!e2R!Gmx(#mQ(>tcz}86KvYudi=+%S}mY;nSz2?>~A0 zDX+_HnM)3@zBnDUG7$dw`t|FAW06~zF3@PpPFDb@8rR1&()052B3GA}m+{}*#eg!= z`ulm~06mS&=g*&aEiNurT;UgQfq|o(8ZXzVN=r+>_0z~T{2bTY4s^MG{W|-VF4>&b2L}|x$z-w_fj}6@y{(rn+yzEy zYWBTpX_=dyn>!J|LS+O;eWm}rCU>7M@~UGv-(Imw@5rphmHCSNyu2T+{D^uRn1E+6 zalehE;K;~`sE@C2m0@cwBLkdx{;;Zm|9TqNVUBdj&kG<*g2p+y zzYCkgb9bHea(mSW^27r**8FRyE^KYiC6_`Dxj|lsk;5e!f{#{KS9i3&`D5vBPEYWe zBHw(PeQay068~MMw&?bQ&n_a#pAJ<`w`Vs-`A}>$ak{W!@dGY zk1kJj>qj|{0Sy^D+nYGm(nitC2__W^3JPHl>U>(-1ifw6^QHFjj%Q|O-ig35vQ7p! zZq7Fg%}C6jmOpXu;K4@l!urpJXU9?^w^$3QrD5gWvOmVhKg(=xrAKXSY?P!e$EfYw zCn(sfbnZFxHsy=3sAxHFuW7uj;xH;|cF6@)SXc-jX-T#^^}%nvDV95xo0AjUhUX}p zU_^7={(g~}V`R@?*%`?zFE5{;>s6fwIM5z{wvEVb35dkL$VznW=^NMII7RWAFEfcL*~X zLH*COc1)u>JZHR|gbG|9_4+iAvG+91$wQaPsbIe+yCO`0NPO|c^`0zwUp_9gE}NBa zx+j2Nry{|Vv|7kMmh}}4q1JwH`(v!of%rOE*OS}HQNm3fwoulPBSqtI-`hDG@<9CT zvOZ`zbo=h)q_=YCQHH?s<94;h44o?R0|y>qgEo!>AIcZn+Sz$6%+<1MP|v%5@BClT z63xet9(5Q6ZOmf)R;GJP+x5|g^tGYj&KQ@<_dj-L*#WEjUOLJvJ5mHm`d0tQa&jUQ zoQ0-7UK)7eTb37lS{Er@FC#F7e*oIN$5IEf!@)T{F_B$OcKswIBz7f!U5t8XxE?5n9lB4ynD$LWS1atv2wyH3cW z2VZeUHDuzIS#WUGLe0eeuS31Pz2j}#59%Ff`br|7l$VS8gR1NJOpykwkUCHdK=yqq zVtA{^4tIsW7#%Y4u7_QD21a&+3~<#W*J^FhUFAVJlq*dsTB=+LzMr4*a)j+D(aq~C z)ryOXW?xqjyLV!mgi8fKh#pEZ!C>OV#l-59)6!V7tntmp(>B%d@{38AzfV7&VvpqAMw5?;=S96;q_MB^Qef zuORKv1tNsMsbE)XQcB&j@WC31*gU` zYdM;yUVXT)yJ=}_`$-Cbt*j`=-2(m*Z3uICMlX5vXsl#mVIfrD9;c+w>-e>pRc1V7 z)~$GmwHvmuc9XT1e}r36{9Pn}uZ$c^PnT=n!__tCQAGvqEQLZ*=#}ibTh=-FiF-Wh zzCnpDKNgFftgkz2XlSTbPm9$(BsBFA#4+<`dV1RFkmM=pjDFxgijZ@*FaE@?Js}0C$-$N521K2!Ww%6@oJeuM71~?otJ3U=n%+L%19x359#@VKN zgXpHubMMZDa5k1&I6Dt<;?wMU&gjf1J-nJ% zRGbqN7mv(iXoaG~H3Zbk0Ui@(n z>X1&MO^MuMaZOl3e#10BbONpsTUbiUrrq~ zu(9S*zJ+5P-Y+h`w&YwFbi6t65}i)Z)4a8_c*fAQD3JX@%y5pp^!C*y zZFHML?%s@-;7H&vLo&7H`+m&H&KsPFieP5w@x*c0zdw|N=R0mtetWL#_KGhWoB*5* zk<|nX@Bgxd^sARQ`~KKK@~YSk=EkUMic!=JeyCK;iC3?H*%p8h&ps6aX$L&nf~P?P z21Ww?Sk|UV8mFeNrCBJ(%mSfH$2D}uvOrP^%(@xQpN|0p6DR3y)gu=Pt?-z-uxo^z zH9(u7NHVhBaT(mST_=0qV5mVEmDn_q#7I{8879jMOc&uB-&VfW6$?s8QlrBO;6QMy@lNr}?k-Cc|B1_1$;ZV*8_q`L&9yBj2=yW`yMz2AM# zkMH_^9Ii`Qzj zd8g^3;$ZILZUi%f6E$@(HlviXGqNyKHZwBybm}t`go8tHwo=t}(Ug*aWhM+4_+`c6)$;J z6E7PRK2xf1pnb|y*=Hg+g0I~ywpA2T~UKPL}AJ2&P3d{Kd` z!A#Bhl_ezq=UU(=Au3B37e{_pR(E%I7I!F%1I&VzosW-?m5qaygM%5ogW1{B-o?m+ z+1~l}f6gFb=4=A9a&)nBu&4ZYMk8YfR~I2FaHs$M5$qi0_RjzFpui(z^)PZ| zWoKbywX^&8x&G_j&MwMk|L<-5AK&e)>gj04s%+-$;0iMV>tX)-KL>+n_kX|8zc+%r z;a7lJfkiR0m2fa|wKKDKk&+Oi0)JsKwKC=BVl&}nV`DRBHZn2eWac!6nlbb8a_};9 za&U5SvzhRio3eBK=RE(XdMcKu;+5U5^ zl)bZyk-dr8f39r>uKhp9^8H_r{_|Fa7eEgf7OoGl$3DaFMp>Ew(| ztnB~&O#g3<{`(_Kn8B>v%uFR=4tA9P^^p0k{tq``#0iC(7@HY0o3KMUm^t}4jF@?O zc#WBP*|<5mjM>Ri!cFuo(1-9eAm;W0F;D`T>Br|&u zJ}?j`VST?x;NZlTq$EUDJ?8f_P&`z-=X+^&#=XNokD{ts@8n#$VO|A&d`8!gubLW} z^$lKH>G=u6i%80k@R6MKVmle5RPW$X-zk6iaAa1M<^vtzOPiZ><`!s5Gp!OhIvb1| z5;GskOj)^UI}#Ww`}Pf<&7^0raWE!u=o1k_8WGeS!`6H-nY|)~-K3}8yyw4viOdZ( zA1uMJ6%Y_`vtX)j89Wzuz&H2G(qc}<>H0Z;t!VD`d&;+H9<`a^uJFiJ?X&u`f3ME2 z@Jn)vi{HMyaOZH_V|rmM5I7UoOpDy~s)+d}-#`eImspAebJWn5asqs&Yn8k)6iqB!SPl_@QW0#q}CdxU{5%NwiRPnU#sDIW9Bv z@hSqB)_x>YNFX|(MHl-%Akf}VL-(^0vRh5p;1kO$!|C|Vfuy@nWCFFt<>d~9&zvm0 zz3-7| zmhaZXdKBwrC}p_#XKyiTtWqTWGH%!KesT?6U7zPgw|_GP+%s=hgRwc*?idh6EIvak zn9(d>C7)0Ud0*OwkB*M!u(6OM;y1kZ8j-xsM`O4hT3lQ#@V+`EBj>W7#Jtv)qHCo5 zgW-tmW>*X?>sNl<4n-&Aud|(ll{7Woy?jZ+V|a@|uTiLshK7bE=yh(nvNM*;N<&SZ z;AB`VF}awpmL8}luv?Nl*c(IUU1ri7^Ay;B&-%<+JnX0bZ;)Qnw_U>;j|i?m^~p|f)F(_wFDNKzef$t^VLr1*x1-R<9RZ{1kBo;&rnedu_+KQKq#l% z5FJm($H$lVej=*kqoK)1jOtKpmk#TDIeV_SfmW*iw^*b6(q^V?$wDz_ep*uOWs&u6 zFbX!gew*JzN2~AMItZTefXhFaIaG+ea8&55&*~2lUxbQH7`pcCjg7n#54(;f7i#1~ zY}J~`mrE1r#;z_7)#EqB@Y|j))j?l%O_#*N93{gpuX2P|{9Urg939QD;kTYFpk`rV z>9RR(JUu&eHa0OiO6PaoJsL{m=}M~f6!E@Sf9Il{Kt778twar1odFS%LGoLF_n>cS zS+G!C-a6lC(B@~SU1L2pKQuH{MRR0fWVDv`K8955`pExD0Lytq&2>)5!f>K#iDMR$ z;s0gD)0E$nlO^bRdcEEq#oqSxc*lG#BGt}Mx%@nISp~9|qGjw{8Vy;Hk5!gI zij-Zg+1d_4#T9!z%X+HbTW;}w|8hMffLL<}{!WMJ+^01SoJj-cRBWAmn(rz-(5_xO zH-M`SWvA$vo?R*0}&8FF&L}S0BO|7hg=uo@a6$5&d|YJ)wbt8`3&EMxgokZ^D8y4 z+w;Gf14*m~I|m0s33pf4m0%Ms$|SM8^M87{BD_3YO8XF?OLMFp(H-8;psH;vj^JBa zqj@UJ6hSkVyK3CeOxvD_-kd1(bbr=SWj?fvnb4dT7Z<0)ji*xJ_GXbQRE> znOxZFAn5Zb7SLL#wabVh6Sx!=5oy=SCY~1Uipj0_y4c;sXH+L$Xz_N3hg1f^X}^N2 zX_X)<<=kgfHJ@Bf$1NYPkw?J6zzs3!B=Nh!B=pAn4Rdb(kjGBMP*zX94wCF>~Ak6B{gPcc?7n|7zvNP z>2tQE$N)W>7w^#vs}%D|;1FsNdJg^H;bV)L7!n5QGQc&;wsZA%xV6J+v{4WcY&Dto zeHHc1=Hus2^-XWCu0HRf@V{p_xxYTC)YP%`6iY;`lM5>Cy-;`m*5rN^1ETsyIzqyD z8ta=Popd&eBWX{>dvZaK&oTj@5K3_%+J&@T%=v@b$J46Y6IHKrlfTN)!SzNVV0m?g zVJ@}ZY^8~U7bJ)fcXpd*RV5`n3W_H`OUoVZZ+c9_Ty=aE7Y)n*l7z79?xaO)YioJR z8FWllo^JF9KqkIB!x;i|FMoTit-M~cryhN4s%GJK#%Xw^=G(`;_>M4G2Z0aXXDF4c z3uFbs4=hP(uGTi@=3DtW;^BTS2lF*zGOj~*Xtt07E;8hv2FzujvmpwQQ5#cE1XSC5 z+ODT{91db)V&uD4nV&;Q6cWE2)}m?7#bl`2Zqdctc2(`bI=N`kQwcXj!Yokbs=JclLO* zopt>#ir}HqY#`ACWBCQ7B6RVvsP?w9vE~<*HOrqObO_7$#TZ?sCtdeap!L zC1+O-UH|&(98wC;-8@tVS>Ql#*m<)WBcarGwV7n#=DiWkLz8SX&tMxlK07!$- zT7mqr6grtI)@VCEIZ3gNu^H;`m)5DZol8t$)Bq2qQGzm29scB^-PV7<$oWms-r5NY zkA_E$Tj#2Ju(12#A~!k#Q!?6}Xh6zW>xIs)u9Z+UeF?#88&dmXB$8ll8=uDavE*gp z*c8hg~-j-S_M9(=Ou96380 z%-*r^|IOT}ns!WD3_bgqetUVK<9~N~;4lNwNG1IY+s^*)&*Uios076yeCz~#WoZwWv{upx%2&>9&Iv(eN&^TO+FOJAQ@W`yw|ZuCJWb* z|Ftx%tAud4y}j+XJ6V`gs#C}P_ne^MF^NVtna%0x@nLRu=R*x<=#*7OcQzIlmKy*( zm$8S3hvKpgVNi=+3I9*4iz|OUcSI71`JCG0nm|x82OnvkM75Fd7)FNYVJX@xj(;<#`@aKKZ2Ab zTo2pq=PHPOd3RIb@J*QEvt|O0ZXR3t<=V7HFaD(d}&W-kI)hL|v5?OBg5RlnkW)Z(1wdpTEUsl#aUmf~{krp|sT z6^_9@wBx;;HR0hr@ge+tmo^=Z*hX9{3Kr?hT;;+@Io_1YKf&0-*YML^1 z!P#q{C0FOJX@yNYc%G4T{*1|C7hc{9?{2Q=c0gnZ|8?u+Ya)o~!^bb(7sB_vyI>^9 zLfpY#*MNJ0EqM%M26+~7vH+rMbaHdtg)I=DkdT)O=lVCD&p9!`B2HeieVj0HxS|?y zga&#YY!QcX+(%=pJI~+Sa?`pwSkv%!21Og@`we^MeGe#cHmD&@zi0hW?rd*CHxs#g zzIOX_uBUzvV>V=ONI~Spdi=Z82T{=uGhBQ7zwar0&-swKta&-B1F;ZAekf++LWSzX zYAqBOFcvR$)4UyhDUT?0Feo;T;9tH-23}49XO$ zS8qS?VqblG7p0!vYHIs7#Mc9T`|?abr_U&}(r`xdRMrCP_+oFS7YP};)p-B&Gyrom zqCAf;5=j}`ztVKWi@&g?8*ux%y7 zIlwcdUc)k@4;nc8K7%P(@iI`MPs}9iE5K()8li=u(U+ z*-!qEJtmLhv76_F+w-RBUg+V_eT}R7@x*$lLcLhMbg@jorEvswTLqi0KNPZr7t0OW zC@O#03*+cR?VgiNuvaDDytpQHrnQBSB1yW32|2C*ihq2#i)Gt7la`iVM+Q*j9<;lB z5)|Jr@tEDVh(`5viW_o3!%2aJf-=IHKYo3E-8Nt6@MPxXSlC&6fY7(h&5~(X3+aL|}oWGMtKn7d>1G;m}_H{e@ zQDN*-Z8>FekqyOx*HlzK$QUn=quHHZbJF78dY=9%2Z*~W;82zWnG0&NMuTe|S`lm3 zc0Mj+kjr)7bN(Rgds`CXebj)7vTL&w}qg`v;fYBp-UzT(K z4a+A~y@n~n#5{xMvtrkb^F|8H9q>xzBqRcDpi*X7z5P*L&6xwP6Z|bVw<_>5J(A(B zkeX)1C1!07;2kEDY!(KF{tRBHbz;tC=GCAi_@(Yc3~O`F{%8f`4vjFYNfPRbo*1$w znzwJ?MqN4^W?|nP)U9kyeV1V=q{()b@3_A|8%YZd36bU5ddzj50zH5fzGItbfz87w zYI9d&`_|*VMyGnNyY;13Ux5vf1lg?3wH+Ogiz_I?gZf{G6mxx1CoAJEoZPju=72(Z zyu7_t9GqNE*1HQbg}lp<5=go?;uKxl%@v{04raprrT~(Kt)){?M4Aae^CRR*&B(Y{ z9~(QusCivL)bf0GV!WPom6!A6q5qz{QMf*xj5>8*7 z#y@YxKd4x~Mc`mN20hn*UTWy4dTscwM|aun-|gCF6H_cCe{Y8o?Ps z6gVAG-sJLkeHv7+I!i`ICZ@p@POEWq%GRU07ZneE$!e^NVgo`>n=F}N@uq`<;CGok zL2IJ3)Z~6uAV0-j$5@s(4Iru~(NJqnspTFZ4FDSe4LpQ+^6MZ%3sR;On@bU{064Dl z3z5`$Yuf1!cm@^GO;Hqc4LfJk?whB<-l^Rw4sd&?0!VT+aW~_tmw)==ya6Hkgi;+V z)m^n}`vR@XnkO)?=bGI5{uloD#zT-OWQ-OUQ}g}ulvB7sSJ(=I$O_)`wOm`-K}r4a zgnm`|J@9HBfJBT`e z`|5Q$1`)?E+#M|c6jGZs@yb5ESDT>e_kAw>rP-1}R6t@FR5L~#VVBGUwVpxc)t=ru zuKtG}5{G;EJ4bf8CFR0Nlg<7FT(*j#{P`_-9fX>url!|EFXZw|{{TuToD~3Rye6${ zdNOS-NAp4JV5jV+7qS~#`VBDZgU(QN|8e#CX3sM#Okz&Q7XeMv?F8w#kATC)aCdRR z$S?emqlgDUsyE@NaBgZkOaIT+L2I3w6SGXTY$0grpVGSJd4xOIE4d{ApaMZrAVC@w zBpD)QuO*lToT*=Q?ThR00?$n1F(3tV`yA$p1@l2HHwO!K&=K&l0&2%WuiuJ>=%!VO z*-aqW>H>CvQCV5ph*FX3bFXnDMf426K5$7U(Hg1xfe-Z-!|4s>)z#Gj`S!nM7sv$M z&T*9OOdverjAcrDAQqY}C2W2FfX;oGDn6BZFj?!Tj6BUar(L8pu;pmBQgwpFyLV*xf!bB=xhzD)Fw^dHW4R*VKfj;v zzL^J{)BE6ld;`fWa{L# z5lcKB%ePP>7c%~Fk!TF&%K%xzze+YXu(pGbRIef@rO|JM`tA(5MyQ@ossH)&2e0SX zuk>$)7()_wYvB})9}8+bu7;9d>M)WlxA{MPsdhJhxV;E34wxr4wrPH~|Lo(9IG%0J z?`N|Ih=&S#u{C{D20n?pCl6OEPg0Dkpo)--z8Niuv*aw}_4BBo#|&2>tOFFi$_~BB z4vM*{sUTn?)A|}#8`4SS?R&Mi+i4C{G6J+0B7-0$RdG`Ju=}-W*Xzi}ok){I-oCIc z#OIoV6XWCRUBWA$I>{7(%z2cnNOvl-iTv8k_^^IdD2`}N-kM__z#Xp`WNXuZBqU$9 zK$omcW$7DS`DNC>eT7QbB z{C2S5o1L;AL$VurmJ$D!3}V)$=M00nd3tzMbDejMPfQ%$-QBg(uZG#^MmH8s&kDWf z$y9W}S5s8180zjeqpL6iH9&9&CY0KQuF|K)&~^U)L3-Z3?sU`xnZGj+bcHQ z!7^GUM6RC{aExop%Gu0Mb4FFIOky5hJr>qFQr zCARe_Pq!npjgVNk7B}1=f^~!B;1@BD@Odzer@XE*2R+(&&!Gl)OuBCklbU8%6z2M9dT^2eObQezH=YiN&>5|Hy#^bO|W{FLhs#Ynbq5JR( z@M>h1ET<FFTwlAuHRAdW?nc@i zB#!64$94zQ#13v#hGn6!*he8t)67cWOCln$=biTn)r+}j{P;_XgmuqZh;y?l;O^xK;3c*_#R_&gy zUw`%L)sQ6%0~C5zDdIEaZ{+q24r=4t3mMHQ`4&dPte0O{E)_?^T%kB?+D#Byp6BQ1 zCuhaNz|4H`{G|IgR|6<2RmOW5zIorjFa9X}y|U8Amo1yEWYN8}xF`lr{OZ+O&Ij9} zY;$u)r`km8r?Zi_s^a>j&gL?iYu2Sob`};EMbY?WK*2DetYxvV8y-YnQ%Ar`@%W$; z7!@Vs3%|>tmd3!iT<>J^9t8!3A~lNNZC~?SGRg3|jk!9x1$x6q5m43Mj8BGgYydFk z$n}G<4CxX`6~u6nP9O4=wR|r3=WMeDSkoaxgNgV8*wF-IsxO{De{PCZaex^49nh<) z3mAlWc!JdNsok@KwwTthrdYylzmKU-0vtz4DTqHa{IVOl-gpM|W#mKMf3~-Oz%QhT z^?yp~D9T7mx(dk;YHx2x-hy?z;w?-}Ok`~t!(cGwz(+fa6!t3Wsp$&1IS*--i16@m zo>BQsh@tVR>{+~{o7-i6Lo!IZvXE@Xia+pB^J$st%UfG!cR@x*#<+YIAQZM)tJprP zB^>H*j~aOqQA{*BXe(9elvd@$%4TxnyjOcPqHCwsLx;ppVhGamLkhXsXs>zo~ zy%W%;*`}iXfi;k@by4A*F4ET+i&yAbvCu%F(4_9^!Q{W@y{||-Iq@C$*Wy&**j_P~ zsRhc?z+nLH zP&W|b3X+Y%DbPKE82fyP0(8JB7wvEYhMf@~j|)|)PJbEq3?`~IQ3(j>NNtA8yn%gr zna1F0PJ7;@%8)0WnU|l>fM{s$2(5ZoAjk2nwgr+-Olp$0hyJD4kE1?i4tcE6cM0!k zYM;Hfkt0F+9X{bdu`*fg-(6SF6Z6>H?t9vszm-gyPvy4TrMv7-X0I|sfA*|`XT8Ia zElIUY!_3S~)x-f%p!=32=7WfX0CnsN^5iK?Z8DRmRKNhi#i8;6EPkRH6C-N6B*5!$i33p5;6f2N=Gx5=orjTtx^pT73yc+@G zG-1zs+X9*t)wINTAdtdE^1V`PSl2$&0r{w){W1VqHoc|iQwn}ZsS%`cT@^(K_D$P} zZZWK9b{7pD4XpqR*BN9a`2A0o|8C69**O>19Tgb~gLIE?7a7}@!gu7)UqJMmd9pRF z!Fdu8NB<2GKGBtq6^=Fm3<@ro4jo(SyUz) zGoq3!;SDYAj|tsRfS{zr(VrQjv@n-pi({`PGtCv2qb@8gv^X8?d3Kb)C3SK=?;n zGSGoI6Lj5Gm3O79HP3aPhlbCC94SlJYF;&xtR*y-;HxWBDd4g*nx3C|e}7LK0CVsY z&yP8KRLcIy6YLsm6~?ANbf$>G*=3kvadekW;Yyd)&N`n>gRacL=S)c~#H4KZ{< z)rQ=@FgzS3!FZ*`n~?nnd?$)7F;|}I_Vq|XLBSm-A$45e;)>thf#Fsp)WjO|4QK_6 zLA@y4il7koxrSs5BE(wPqYH5X*)K&pjp%8n?Qu$~ovh9TmD5~Z=B%oBuO@ka38ax{6^sEXCbmH52ZXYNhN|kQ ztu0-OfV;i>EBEy7xxo_7)*gJFr6T}N9?Fih3OYwY318~WR!mZRbpX)XiP~LjJ~f?6 z_FcJ9+AhP={?0o1>Y%9WgsX7+=kwQ+iVbTYOM!G{uofCm)0z)SbLmb8CL zbaQVkg+Vw=VW)b4SA^JlN=r=ad9mwyFvC}T=?>UtI)YK2>zIiM zdgecAhYV-a{)+RzrF=OiYP=)J!tEa@KMkwf?MQ0 zntG>!Pv~g(WQJrnMPh;IW<~Z{O1hC#V0T871Iwb2%j#n+p61Lbh42x?RW@FSZfl+d zzj`?Pv(26I;nQ&j!Q>iCdoc5>`DJEW+HDQuZeYR~J~=%lHqh3N)OD)P@(^~%zPZcW zJb1PQO?e)j=hQtmaSa?l+XAk;oIRsaAZgmv0KtFjA74nl8WY@zQTI_hH%!qHvx_35 zXnnZ~sp95H|JQ>m4x3J>HLesinnV~--(fhrmz=KMy}brOij3!UZV5Edik$s`_Roa3 ztAGm1%7>85ffo7+vm>T^5x*#^O#FnaO#21AwRg7AxrJO$xSd&uH6eOIHCOkXTQW(^ zy!vH7WBGmfWsdLp&RCVlZy6E;(6b<{8!p9mY`-g@xpGMZ9%aChU=>M>Do#Uw2^IB)CDlyDeHVtGADS&O59_CK2*=Cqml|6>==6bD zmpAl$R+&>)#M&dGLF(RH!{PUvEW8^a9_I`IkzS|r?(dbiJR`@oK#vfjM9`X0+-mw|h zT9{2`j&l{#Val(M$glBr$lhiE@7psoy|19@Y3mfC^O&kll-E}ZO%m@(r&d=#)@)?B zsHCacI%7Dc2Vs@Fxd-xu((D5Rf~09zjU}xNK4Gkggh{34KJgHh4T8)FkT9czPu!%C zLJL)kzNSDeE<^ldvFOA}n)w3zWh*mAYqYG{DODKq7nK>AB|Yg}ONLI~6ynZ=@BQP! zmlRj>jUbyvjV^yVAdkMRF@-oW?1V9VW4fPKYlz0AZb~R%9ZJU8NKAT$L@wLI8FBdW zZXl>@jvYuk*9Xkz4?HhwUsR-|&~duL*?N~9eG^_h1_JcT7|ap0AJAimG3`jU7<&DE zQogMM@~{`Lmt=js=#RMRY8_S{Y3j z{CnS<`CCw!ZhmBrhHCR!ezger@OMCTGr5)~;-9|Y06b%bm#&dfQFknyoac-z5$Zv{ zm62#P;e$*9Ry19jj)D4ni|VD?Lpgt>y5%M&y^-_I#R@Ybb>@hB%L z-)8HrZG;NjtKMOe+uPeO_x1I?ZO?k-Bs>LOYYoovrff~hlv0gbOl+)wn^u*%!}z3{ zre?BIxqge)&CSiVEcBNM`*478#Zb1% zgi~Jq@$`?;Bs`g7Ix8SABz z_W09$wrKE8h;=dUGjK0jIJ4K|x;4G2$;tH?mrF^OyUKKU??ClV@d8V#Dqnh5(Q1H( zA)A>ZQz^CCL#tmvOVf@%B;>p`C_Ik_}c+f?g*0BpP-ppRL8~-W&tF3<<~xOI9p&tgJ^r!^3+$_WSbS z=Fv}5V9;Xb0?1ln(GGnw0TezIz z&R;SRS<)szo$IswiOJ;v+v@@NujZ=puXe^ZHpX(_dqb9no+Zl+mvHMjmWIrM6%;Zu zcnGO&1rV&6XW7GqXEakofo4dS7?Y!SnC$vgBHB1N3NS>&|DfMdHGco*X#l{5EUWE3 zReyjOQByj7kCW5|n!{yxN5=vMoiX6+bOS8mCXV7pl&~xo&kzzfYMo?_f$zoC+p3>G zzgjYu0w)hTpeX*LYEng~m=)Bh%m2Mv@jsf*7Dd=C%mr{kLErCY^C25XP5L)XLlb`X z9_d-n+)gI|QRuHfS4aNi)m9TM)r8NMZYb8`PD~UV zR&`M|6VtVZL@+DrQ&$Xs{RJruU0h5oe`5Lu_-@;PAt%WAR5x2-07Ju0u8ljSd-r$b zo{F6hZs0m_PdHXd=P8e?ljGrWaxpLzy(Ms{V&bbx{ZxGNPT}$&jfIi}i0^Nwr?4qN zq&K5lI!L~%{yM;9Y&LA?(9Q8@m-Tc3hz8DpI)3O`Tm*WV|HI1DW0D+?nfe4v{WQ5b zP}DBMeVy{y7j?l-9AyOw?z4);3)?D(eM>A2-TlA$ZaTb4q;kqmSKxWI04U3&udjb$ z$vD#2$A|o6&w#U>mfG-Vu&4`ZW_JL%pGjI;TEfStQB8Kwx$Wi$rDbJhQ4&<%qa)t_ z9LCYGYg{)jil!Z#R46<@KW_$ByArOgzCVBe^3k!d*oOtku)HD;A%Z%0PZ1N|13cjH z5`pXI@87>EC?^|2w37mN1$jFL7w$iMi{$(wg5{)jj6Y9ot@zXcGWr~R!Wj8^1yrV+N`ad%`JssU|j`auzA~wVJRBD+-&KJ`8>{Gy!RzL4I_U+v(U|)?1 zd1CreT2)cOW&tF764A{QNwLbBzN*0@0nhvq>fX9p<}fF>>HY-9HlD44?AM`N!x>Ew z_(KR{Eeq(bp%Cj*DY}2uiZ8%$GabU)HW&y6vEtpsIB-q#BP>vh)4|UY#M%$tv35;a z!oFVG;6F){GYELj{vjbDgLDiG>$o`DP*Ii(A$m{NeG26Vabg6FY&vXtTT|K)>z(goyDr_3=Qe=nbnWZ!@6Q4iy3Vr7 z#YFfG9oXlnb@q(DRk&d6=3nMr!1y#{TOmaso)^Z|$NZDj> z8F*;6fYYYOL^00VZn7*l@r6lTC3kD?YyWJDw@IX!^!0j;E>`BiE`m&Sfmm2T*W|d? zISK0Qd(=0yvxo%d#)oxy;lSTIfzZ{}#nbh)ZYjG2u$90Z&?cv-oLo`asBrBg*trYm z{~T+uKdO>|)-Rt{A@di>oaQOHkeBU0_q@SjM+Z#>TW-)T$bK!^Z1C9p0(0ch-UL<`x#)av%$<0UfYR$v}e2i4<(FSkOJ~cC4C9LTVm> z1lCCzuVX|3`f%pwqR5`m#hE^=5O3%G!IX zmZCz*o|^d;V(?PRw%faBELSPW2WVDM-6eb&ZD zQG1lxa}}L58tF`)@Y@hY_pT0C5sTOO#bfLO{W4W&@ekiN>dF9bm@Pu|ky}Zv&2PDYzrLx#LsQ8H!>G|M`W& zLPJY~pSn9{Z-Eh`-Wlw`Hki~;A119Zih>_bUD1CXdT2(h5p3bA-Sa94gGrX}iT-&y z9Xc(F*Voqxs=?I5>ro-$&<;Bt#WOKue=xMr2RiL1_(#H&%8M1S4Sj$*@zfhQXwi-y zc&5O=npwYv$QKVBa`Hzt zH#avH7BR1{W;N^h($={Uw)&IOc)V#eGRo@dJ_9>7M5jiTx zNaXx3JL1pWKSs=a53--L0O0nXe5b6Q-okN4YevT=bQ+`r=<7Fc{@|*tTj>|R|Mc?Y z24IFvN0GC<&jH$dx;D<|S^QrFC*ouoQ94PWct)s^@aNV06F){?s4T(aRf92G0fKTE zDAB5d4>*?PN>r`#DF_X4OXhRV_qVq{j6LpnC@fU$O1@nSfFLA_6{sAy(&1?6OwNaB ztJwxhc2*@f!vviBhaARNiAhQKeF5uzW!P22d2t&rZ3c63IhWl@%`z$TTyk##5NfUp zxaw{Gz&tAovl2dkb_CS6A0tQ?@$}h~-&7=r<}`M{8>7FO__Y;BxUL|xBWV#D8tO|< zLE%3!Ig;3CDq8V+-w&iEi{l>x`OExwNl>J6z3guJ76}nxmz!he;&N45Y4yEIMcDmk zGd?Ilzb(L-i(*(Q%tv=ur`AXOcfckYZQ>0T;39R25F~`+sH{^j zoQ3ZnS}EbkZW&aOX;||eIRLrj4?q;|2+)$$K(btF+tFa4%nr~fWbR{Dhj)LMZL!hI z%S&E3U<0zk2-u(_tHIY5DcNP$K&iNL+nu!HLgC7~DPvGFcY?lTmacpkpO&t17y@p) zITGOg#uXBW=93~-hJ>_@Mx*@<7Z5o}fR7S_a~aVnGCXmQ?iu6vT?5s;3m7{Z7D?@S zI9$pWfkWLd9gLfyiisRo?kax~l&!gdnBX_{sJjz2P@z!3i^Jv0O)C*JJ!dL4LJdY5 zqNYF^KtF0e8y@cju1_?mHgMkc0rszy@oKJYF}OSPQwKzc-F!Gf^UpD@ziK?~`LI_m zH@gK1AwK?Q+tsr7fD*;}t+IcaL(SH$Hs#DCOERIx>jQfx69Uv)^7;(+`Y{RoKYls6 zo@2xGuMLS%OiKBH@gI9|?sy09eZ5`ep>U(E< z$Iio*`g2<>2m>FN5m@)_Z>?PyIjy=`*4?6H?H7C&IhSRD-X?m68W5f!^r1ImxN4)H zHE1;z;q6{BJzbuk`)q=4*xa}1b?~9~BM`pYya3i9*@L9h*Z)N;g?ItiNA#+Y0p!Rx zvta?s&wm?VS9ir&Fw)MEz9$Y`h4`Hp?6LDf^VS^%v)@If+YeCCBP&0DbqmNNWD;+$ z3O&M|0cE%nP+Zl-a3f&~x0~Q1*v}!l{ct=lEeml*%bQQ7*_wN0R$GQS-0wPsb7t$E zUe6O;V+k4bV}(s0Y=GPLA& zv@FyLLRP%UBpJ=GcO0ll85Sd%4}M^H(p_{@3@(SMQO<1q9mk@_U7_AFC^uXbpn|7? z(UXm}EMB#)Jk@mIr@ICnaUE{j&4$*IF(u4W-wxAis_z{ojT>G>yJIo}`Fn7#!K5w0g29_FF8u5=Oa!LR zF(WYE)dfaz@_~E&yu8d49|;MmA$zvAfAD4PYL1`cQwP ziK*ex$_rS;!8_nN^sCgVvu~-4v2Y8v_tFMES-E0&orHvhASIsehi=A?5&W*tO<$SO z5L$n@+Mk=K2GBpQxw%wD>B=FtKjN4_tK; zqevnIkvTPs2mhv6Om{|eOjaLafDW*unt`omdYqGrym65QDwq=jLNa1%y30320&d^g zt#*Ql6~}tdnZ>{WowJFVnVAE?xkkL?bzH?MARQ;Rq{R=p=h%1g2R;u{GIrx%dJrBx zj8nArR`p5vU)L>VFSYqld2rbM`j#k0BM) zmxOYwGT=q(;GD|<9U%#f`nj6{KYAas7z?t88qbRS$R2|c7;$k{1U{>-)rVa)(L-wo zhn38x6)tPGL8%8|yY~-0a3@pQFwXWC=Aw|%DcKup=U(JWLMy04K&~cO1%jPCZ~>6u zJ%7&r!cF~Z-gXj*R@FfJZUfuEVQX`935aBoOH~#l!2sCt8z`KXiXrZzK>{$9%$k4) zv!UZ|tXeb}IR{V>rP(@5$cfu;UPSoYEmK_M46cB_ArQD#C>R+S7;rfD33;#$`nw)X(=cu0Eq-JQA_sv zEiW%`_Byr!invB&4wyg-gY(uJzY1Te`e%w%8oh`OGLjWWc2E;ZLj5&P;A@G9&}4m) z_@a_0Q`Z?UK=;AV(XoQ$lYh7v35Gy3)KJ|9nZmFe+J7u*FH(5qS|i7}-BN7F_ZW`hpn^5}pL7#Ggke(%<22?jV^pP90hMeYp!xLl zxBic)JE4D!M-!jLD>KCq40z0>M2KS8ZWudMUN49~vsX35IIQ=%Ikg806bahWg<+W9 zh1Nee^FKq1OGgk=66Ip|%h5cl-A|0whCNMXaA1we9Z^SxSW{FTU%*iIA%<^(tS}9@ z)V~wJeI3I_kO0M#tp;WHjC zLZkdhuZ3Y6p(D}r;00XkXhL$wizi=TW=c~?=dH8>JgPuLLxU%~=|#^lA21BW-dxNW zumUsDau*zRV2>zGpgR1)8e{Q1c4nZS&`F&toMZE6M;kbRMlXR%Vhb@jsQ?~8H<+ud zs|8(gZ|0eC_Q7Jq8CnpLofjoswHTafDBLIm6}!2sjs&z~2!V&(KTh2ACj(_2Av`I* zDPT(MIKXVB8_p>=@O)yfy_dFym{yzVf{|d8-=`~p|7oiMJ3Jg`i=#7?1vgML=Y0lJ zU@Z;Eh*Q|SDetx+Nk%veQO!M3w+=ul*u>JitOX-VmUDnKqOn9P#y?Qehiq(40#qjm zxR~`N0EHLB*qzCdsj&;-5gKI?P1)TSb_2}~XE0VxG31NtW#Npy*MFpYP+DS91voaz zX9_mF`!^m@3oN5jz^F>h%g;X@(Z@zjP5q9Zp1u#XAK1Y7OaZ0>HiS_)$_u37cR3*0 z-6AwoTC`!c>e)oIH3;?7@{}*XbHE!m{62!M=LF2;l!0i@Tn0@WhCKiv!CbgtLl@N|qRxe5>o8`Z{2`;=r0ENzoPlM9WT43(!sAUqBqi z*q@jA`DOcq6w+6F4#Mc4?rN^21XhUgM!m#@X13;yqh}K50NpM4Moiz z9FCq*z{R|XkcjkKU&$pU#&DA1)x@`afC1e^%E-tF@)N{IC#cn(z_}6hj~W5XVPVkf z!-o*`iIu)PR%jZf`fGp|J^W8;i4?oXNqpp5vx52%MOb0Lw*d)1LS+YfB?>IQ#|AMZ zxQC|rXQjxm(OXSbpP8%SEADy6B5GN_CxWxRe@PvMv@n0v6c8CF4bqb;4SehDk+GwU zY(N^p4h)j5MwK1;pwIHHU(JrLgn9n4-agk3NEJ5(g;BVF^a05N#x=f_l*?cAz0 z-2sG*#+1-xqFHrFL-GR4r3%$?)5}3OD#Zn*Uo#?zVlPh7gjR_g4M>7h0`0iU8Z#i- zQGpKczp&vh417X9L=&=a6h<L-Jkfz8ZJF}fJ2hU^b+=w})GJ~In5I~yJMXU2?y h&5Rkdx@G?_zyQ<;<02NHOPv4!002ovPDHLkV1jD_Aff;O diff --git a/res/drawable-xxxhdpi/ic_mp_full_screen_light.png b/res/drawable-xxxhdpi/ic_mp_full_screen_light.png deleted file mode 100644 index 4860109cc5c003773d219422d7c27d1f7fbdc122..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3502 zcmbVPc{o)28$Tq?*muRfH6}^L>|>TF`*JhNHpbR9W(H%-k{O}IgxumFx*~mBwNSApVR%CdUjqd%YD3VbaZ@9#|5B#Is@q zG3^h9GF%TixzP@>XaqWR_b!NO1W_V@%@9x_5$q5SpBP~V{U(eMp^$GZ0+t!{ zFQX`=eGn^dCruB0f{t%pb$tj9EpIV2*yYx5rZQlO(5Sds6<;R zJ%H$9ZTnr9gffE$2?RVM93CDX4hu)axS@e?B!NJHBT#S@%2>i-%#Y*Co&7@bKcVi7b90)g;1rqUP~V~jtVVT{M4@WvPv25W+#;R5JL)DJoTgtxTDVK8`G z3ltWIK_YE1mKZz&Pq4KhSmG^^mL`ZFSO*SYK;_UFKXfxCy1!!y|B5AAg)*oDZm1iV z8}g$H_62bTTz(Lj2eGn(7?P+oCTEr2wwj~AT()L}G7mE7wxL`$}M;Z;VHnV6grO1e!`iL%-wc|8MZ%5~JX&mGVE8^W#WTj;q_h z`#?hc-AN3NqgzUr#5i<-F_OUxrN&u-&~Q z59{t)wwycjXVrxZ7qCxR=b3V@qrSCURUHAz&6f6)_6t@A)B)FAAREXYk!d?T@Z_2@ zC9}S{n0v|x$7J5EpN=$sV^we!_UJ`0isnE}6P0uqZ+Yq>6DxNu@m0wZ`C&+@VKNI6 z<=t120NFR8-gk>tuX8M*+}E;d0M&e=IBq+Lh!%$Zh90NKHoWdLX^CF`Bq}N@`t@VK zwR@3%*Tk8#XQLy(t}N$YE5@x^aeA+^_&nMDeo4iRv|fm8&d|GwI*L)iaEHRt%1hOR z@mi#q;)_eYC;I>s)FC6qFcd`(J@MJUUk;cPq@qc+eQG=DzN4GUjnKQuL-(_?FOY#Y zoh@~(eBfEj5z*rF_ABnU6sY{h!mQMRbwDYs`N|Ci-eJ@HPu{DTR2aJn{6#$P8qn8>pqkK*RzJ|keg<@B+}GcCj0nj()KDLnoeg7#~q_1G$0ZA4p4C#|vGtHlx019saGgxdl^# z7>n@F-4#CkjkbzyTZUSM-SKYcDk!x1sW;`NEU+}zx^**fP+!*|X`25fSGp}-sNw(= zKtT7Cds%O;hgC;C^q-yQ?MsCIp{KJu9h5H(8BE1aP#*!H+-RHbU!aQ=kV zEeE$py<^WQRd(F%xhORT3ycrBHWx6suPM)t8|r872-pYdYhL2sq3HMAYFoJe%xY;Z zP%l!=c)pjk$H(1Xv!{=bjxS0>COUS-r&Ot9bRQE{l68b3pGt0?Dc|Fm<`^d*>rVa( z1(jNlF`T7z`@198Zv}4t+y=DnAuAZd6CEsNlJ zI$Y!MS?!*wMZcJsufMckNcVIB+x7RW9G-(U-D~f=`jLOY$}2w8BWmhi`(3DMuDp$4 zX6=S)&)3zuomrsP=i@{C1|@3J>k3f45x%vt!QuGE*g`o(la`iG>Y6~>gGtyt5BgN02d#9feMQL>KiP46PeFm9mbUaq_5+Lm{ zhsuhBD)p#ZDBOg&zy_p#^O(LgF#&5n2YyX($9!qLRlw2CQ#FU?upWicK3xw6 z*UVFT@#)*q_e7PBDp)IXM>WNnz88_Ue-0}J0=<{P6a5BJC2G6s2MK%To0_LP!v>HF zI^-Vk?w^B>sR$Z|E{)X($G9Dz80_-cJZ_O0DZRzHvu^Uh<@PM0xn@=3UeEPK0~%X~ zODsjQcPU*HewM0QrG>wHd9N9f>rB3MO8781xBcNx&gu;PHbo<1a!a*~MQXqIv1*ol z^PQgd8t7J6=!SRT{73vSrJ7WY#EV^v&D6r+v+gF#92r^rWv#wsV%JbRn>U2il)1rO{P<-@8vTxfhR`yhJGlPS@P}o%MoSEEOFzSF7wH#G z!zu9MwDn1SW2MPX#B}Bke!)rVn^W0agLHwHUaCf_N()#GLD|`*U8=%KFmIz<4%}1S z%QHTiWiuzkR9*;N`7O$$Jgubo8NADbx(NnJi;a8U5=DOcZ6*o)rtZ zR|01$t-Y%W~zRe!_ID7N$8 zdC4nyPj@*s7>G#gv3Ppy%NwF$BkU($A9(C!n@$+?QlDG(j>b2bUi@}IEN#%W_fXn{ zjsb^9WLXO}m(G#$oQpHyYgn!EaVg&Hv*uLELWhnizmX~Z+P`NX~HRd_L4 zqYSz_<%6iX8L(&BCqv4W18au^t$gCwiPrl1_<(9(j?EkyIO65t7^AK*NxLBFKmYYbVp1+2 zhdsrgKf)q&#!SwAP>NxvykBpyG!YDVoN=G^Jh>?q{#NU%mN`2PNbDiag2VXMVCqhhBUBQO`Ss zWBx~npRsHx*5aiH@{YjqLP|?Ec?7%7_b1DifF(JFl++AiQiVlTJrrQN0=HsP5c80r zkNyvtr{n4pob*DRIl{rq1z)?VZ#n)dGAemPi7JjKrbsIR#a*1+_KT!J0Ej&H9yCHY zuUEFMpXvP|W$Jkp- zODRiy(u_?x-ALoOK>D$ zy~C(a3C3b#YiPt{u5?$sBPcwDq>WwC(GDdA0oGz-#%7^GSbPA1u8t%463Hf@iJAtG zI?>w%vkS5y_z`VR2NRr5+aJT94!|3FgUn3TjYClY0Es}ys)v#S$uv}`3Ftd6 z3K*}5p&<3|D)ayo(C?&N?J(*Vlwg9op0*wY57U9G>l<|_F)`|bX$-{fzdKq{1lXXC z1`~lr!3J7V@C*`xOt-Z(0ReZky@}o^Js2JVgTZhRES`XXAaJ?_h=GBQ0R*9g&_lxT z`aa%pou4>=XK!w)Z*E{=Zm6TDkATCi5atL2n1LbsprN_JLAW^*_S4puOrvATc*0NL zM8NkCTZ8{*_!dhB{b?fxZC_VgN%T^l&hkE*7r~`eE<= ze+?cKAO&5?l)q%oPZ5xgE5kqP0GRwyNdz)bKEXhpOmg|!Vq!A8Z7mNT3+)*bga^q_ zuX-?9_x!DDobx%%2FFvNHM>bzI%eCpoe+yeJ>3hL)Ya0dvzFqA9I{WnM!AmSvpW{B zSeucJ$q0$9IE}R$aU0{5)b6Zu3p+?}& z<2L4OctanBCYy%crhKNe-$zj_M*0kU)h47^XO~8=LCG7eH3r#7t zYD~hvBo2`Xgb$ag1CJN8T#7wURNs?j)CfLxMO*Z*17CTfn2E`2otSO#a&jt#>+5q` z6Hxob$>QF5u(soby31qksqFE~b-2-;74C}rU0*iT*VkJ{L_RohUkZspr0zd)e^6U= z?LWI#W#M;#@ zQX4kx=($fgTn84~fN}@n=1fMAL!0_otE{_PK3DVB#B*pv#B`F8@S4@Wk-3q(#a+|m z{8>#Zr#ZpMJGnPL&pM%6(ENIhxXhHAuFsLW0nlw7!rhs*3gM9?&nZ!AYU;x6qb$?& zyR!C(cy*JHRjv<}q;|hiP0XH5sODPn#`&Dtnu+@Pf}9E4CC!VQd!^Pvyb!vNW=y}n z`=z6K@|CTnT|Y0!F2lI$<&DTst(9Bk%WKOzwD#>2zIggH%sS#}T@w51x2m*54o{K` zim(^07pQ}o6;o4FCCpUO4qlw@FgSi+LU!|S5IG3kEytHV^ZI- zUT}8Gg&s?rPtFG*T_ucW@NJo_nR{;K{x28iM9X_xb8A$iUuX$9tRs((8QVKeB8Z(s zuZf!M&R@H_sVDFu@(e1Kigs{#9CaNQZp&Y~Y1D5nzLVEo?8P!9^^|czBTM?PtlfOg-fd#GP(_M4 zA{_65e!Lq8NwKnOuB)o5q9o+aM_jxfgXSFwwi>@%ShzhsC8gBXQZT=K&2|&=<#`9M z=(ilD=_FO7$KJla%df|oeQ(oeB(Y$^oqPB0shJVmvoC>0_|^QVoq^fronKOZb%Umk z_ml^iJ3Du+mtzzQD=88xnu^nJ>%yaTq87eRgGMq0jO9&8$9*F=Zrm6v!M7|~McB3p zYm))JEz?Vj^E;}Sm*x*QrA-YB=i}K|q#WjD)Qqngot_#l?>}rjrrC&=vh;9wzp3xv znP;|TTDtb~>Oq={*4!^pEn~NSO!$w%;)!yynHlmd6+`PG2_#75hhJj^I=&&O=$mHv7X5-1E{bsgHAX*lS>}vJPgaads<1x;@BAUDOy0c3&h|36yr z_t(QD?en{b;IVa$=!CF$XT{(40&HT(Bmr2-wW}c@;TKt;z{SFUVP7#P#hPahNp9Jb z8echEVkDuGpLrtC1!$vJTgDJ$5~8AX*Kj;SwtTQcZDuR(iTeedPQMX$;hO z;Rcq&8x7PHqD4r3{SXnK(y+L-CZ3)7>t)_K=U#5BW>wXP4{~aYp@op zOMec?;d`}tiY9?l{qpPcd#Q>Ng zsbb4zSFrVhV+;Fat>=_Uj}w)SKRg994(KZMsy?ahlO7|zuD>Gp!v*jda_Gf%k-h6R zpqjV$?+MJ4t1!`<2r4j%URp@U4az7eC_vQ7jTcZ<75LKp^gNK3U&%DL`k?oXAPztM zvG%UOUHV327;)LvMO&eswJuGnC+>Sc#e>ML#76YLl6$}0sIfSlrX6V}FLl0oys@kd zkTD%S>dcHt6xNnO2dCbPX!w85yE zH@=D&E9(S|KI(2c+Z~*;p_9)7p`cKerFAN7)mo&gh>hb}KJnzPV2*j;}1L>3CJix8I zm!qWtT+ zzV$pFFEk#AMlyfSN1EB|lHT!!#IIPaxqbWHthBVWqNEB2EQ|vS^AtM^3JONPh(z4V zhPdf&fORdkUe%?G&t93v*w$iB9 zZAn-&k&&)Lcs60#`%yL8!}4{dKnfkXSY92~u&mpTad&d+@O9&Nd$oXt#fuI+p`d12 z^aPw-xQWV2St?)`P-~7~>ghFIoa%6E$MD~j`Ua%8=pCeTYHnBfwq=NLgG=SEG**R% zd=kJS>1gkl8vmHXhYm601{C+mGP;{`a&nBa5);wBi;H~-GCbJpNn2a=kr(Te-Mtjs zdm0+T+G&EX9F0l+uyNJBT>bMfFM~d(z`CXx)skt`;K3&pbkg=YT^)zE$$^#-f{#zV zy6}eCQ8F7;<1fCum20DsZZ=bsUS0U`MMQ^1GQ)JPkQ=b*IeIEBEv>sWE32P2-gBH0 zzJWP3YsKqstdK>^YK`%mnDdu+S_l`0?<6O5baaScNkD0PRMU}KTBA?zSSR05EPUa6 zR?iKq@4o81k?!qnRQ@mLfkwPoUso^V>W=AY+!s^SuFRh!$`v^k0G;&0xpU{PDndPt zk7_@&jKqGM`lMymshAVgU!8FY4hgAOxwt42-tAg*q-^3+eyE07&$A9Ht+T#axtu$0 zq@<)2sZZ`TpuM;TH~RqbOL5w@SyB~6_Y1*d-+yz;wU5MJYM1UBSJ3>e>vE#Oty^$3 zIh^Kx^RTjIW4qp&pxXLk;r5E>CCK~TJsdac&Dn5Z`x5pp%j)8uURs;Uoo~~l-F+WL z0&_F)y#>x%ZD^mrG7hkIddJC}{Q9&u9?%37!Q6%v;PQhOU#}O*Zrk z+o}~E^l*AUMldJds8(*$wJtfM>q+EY2LJgt6BeH9Z3gpGX1%-?M@ZR<|1=nJI@c?q z+A0AxcFnZKNa%NyCK(#(v@_5nGo?AFJQ8_jVeegAr<^m)fql8!b|n%?9j`Mn;D`6BR{%|f^{$sdTSRps;U<`YU8SZ(@p1td|m$yC5 zhzes}yufKhyN?6B)PUVC>p>6MMO$ag{}W%idiiIn5F8(4OC} zNQobCS!j|B7&ohh3~$RX(#uRqUhP03sU=l-5Iz*G)N5?hZa(;Y6EfyUYUwe zZZBiFaRM?I!ZI$w?HRduk*`#h$>fFBp DkAX(& diff --git a/res/drawable-xxxhdpi/ic_mp_video_small_light.png b/res/drawable-xxxhdpi/ic_mp_video_small_light.png deleted file mode 100644 index 6d2a14b12e24fd2f85b723e38b32257d1f658c32..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2938 zcmbVLc~nzp7Jmtg0c42EuE-;xqLSog0m2?gSRw?FMbcJ;kUSu27L%~3EGe6aBNnR_ z5VXiBDi%?wh-`|eWeb8Bt+L1>s0C!Rm5MOIN@vcQp8hfSocF%(-tYeIeh>TmdTMJL zYXShE&G4cJD9`Yv52ddB59Y>El!uYjoh1zvMoQz@Vi<7c3L{{U!DmOo0Wh1pe_u1~ z2mmS)UJy&lV*1!~gnS%(Ne36t7b(#I;OG)BVsrMuQZNFJ;t8BEZ)+MbAdl;W39)8E zOc4!^=6M|u!+{5UgE$BFaO}7k7iZ8h-d<^d4@=o#Jb$l1Vju5>`DE8#d0!IaG2kZ^ z=^iJ{A3?F0{vb^#hCyqbHI@Sr381YVj$lo;BM_`WB19nJ2@sxWhb0i~$+q?c8}Kt> zl-|VLNc#Y~$7f$k&IuDOm5S`~__(+@TpS4}6i49+c6N4nh=?Z=u}TfBWWPYlj>ifl z=F1l7u!JM#iKINC09>-jju6I3oiNHs|44x^Vlw|UERcLIl(J;_c(w>nz(IIEe<`m| z+7f91{GTxXQd<(VUj*X=V2LnB%u)6u(tH`Ltli%`T2fR-WA7*CDVxIHOBZrt_^?3A zpgUodFE}obYi|v4$PffYVA&j)j3q~qU@V0~q+rQJvb7Dwv5n*sh|6~V3hzd@h1?;C zK(w|c6A12PH!=mH*m=0xxlvpRZZ^;|mLZTx*#Zu{?3<_b{TU1WE!LhUhS^e~I7lem zyF3N{(L$+E5-k*gG#Y5hWOI0eC4Tc#kN!xR4vTrQFxNvY4T zKQ{>$DAz}<+>@4CxE=sj6f)?pLGjPV3d5oUL)JZze0zG}-0#a(ZjVKO$K1!H(SqTH%Z3>P)U9Xiwj)(#z(X{E zq|yKspp5_l^8X6_6&JX3$=mW0lgwK`NCdl$bLNUM)eSqFMvr+ExnA7ZNQ%?+T;*{| zCA`c#d(p}ANEp^KYq|x9pUQiR6Mtw-*ScEhxc~aS(V540wBA?S?l_KqK{D*&YmZ&c z%gf6de_o~2A(!tKRe88H$m!OalaE$ybVLjyt|G%`UUe2n#-~BBnpIOC()Yi%lDzkZE>G1K5{7WD8+xRF{THMVoK8XtOV_~?TngG=>WOoLY< zTYuA=b>DN$)TQ3YH^1CmHa9niiY`ags@LXgp3;|r^`|3`w@60iKd4O|0*@~Gnzxu= zuqi>?G_UD?@}z+#d@c_P?zLRAg}L3}op<*9qY}Rl9cNwowMX|1Z*;_&X`gRPVXcbY zwEFOtnlc|Yz2Zy5DkPZMF&pStKpChJzub1}`M3Z)mWm4BysN0fil%zoV5MGG&h8KZ zn)1JyfGta2sWs)~d2Ic=u!f|%(sSnqCG3`o0qUD>y^}Q(wZ`e`>$`_g{%u|9C*QZX zw_8^UdNp?h2PbZ%#smO+b4r>69&TG-Y{rRd^+;Haok(4wQ>Lw+>Jl6hlDr%Fn3nE0 zNdimuPK8GSK~^OeA9J(%ySf6_YNj_5r>Z4cj~M#nEE7|D5xIQdvbJ~Zy2^!-_S5uvKjM|Gr91A(iT!UoV2jC_ zgpNyrC5rx=`%*K*P7PESTu%h`tmW~=#}DV~F1~qOYL+{mP~aY#BN!SAIj-PY!k71t zv>9O*dVTIywcrd9iopu3(aW))D}H(Q?Ab){jvYxSDt^%Eent+RwCh(hZ_sbjNquiR zB7Li|!4{iqHBJFXuhu2ZiP24q;1hfr&=%6jy?n{kA@-lc^QP4K_BqVaji$M0%ssPf z)n?{Ri(UxiQ5TCCzw-0*^KGQDu{RFZ^)ct{eB*!aNx`}dRF z0e5J6CMNXBP)+Rk8+DY6xeNoy?h>giB+AUolY{zx68P?R*zvQYDrq1!6O=U~<2CBN zc0Q_nB`Qde&b4vtP#N)2I$QMG)kJCo&66`hSX*M!|lY3+C_~j z^BgZ1!p0oAZE) zF?-EqGUL=&tT?!eIuVGj`Nl1+9CUkHCG78ASXfxE`ps8ESGf$)>u84h!0oS9;!GXm zMW%>heZV0S$sTxEMC(mVOzcI0Rp^yw#&p!?!0x+;boy7I%#O^QSCb`oSX4+Wxi$_M zG-%Qr`>$Qw^SZviw-U%Yo7A1JHxj%gU2ACge%Hejq4~R6=fRO=ZdIn50050d%BA1=hs1F-11t_8@k}5&}cJ=8++QRmLtkh zWT)k3uu62Nwd}gE>9d)jN#OifRf0r^)rWVMUb?jZ32xBO*Z0@%$Bz@WZ5J(>U+6t8G6)#Fp#z8rBhTIIWMmUMYCawPU`-^Z8T-QB7O2gE)Yv z64z`v$k@6$tNX`IerMXGQmKz}>DwRmIQe-lV)TgjJxZTC$SqdyESONX%^|7KrOrUot@oL!Ibov?>`8iibtsl}=nw08i(Br@1h_?PVA8uhQ zdx8jn0zy;u<8g0XXl8|e#f=l!7m9Du&1p8;)+)Du`*Gl&!D*)DhV_WC86t{M#wtUG z@AI``=?%o*N)OcpY__aBe)25uZuRQ^T@R~M)*AFwqQ0U+Y0tCC&z+ien_O6@YMXD~ zt^GPDAf?g4g5+{k<&FWvycKJl`e?(N@UB>^!St4h?VArN?q#T^w2@3cUUfDn%~f4V zuDmipzoY=Z=p=XTb){&k-DYFTBM?_~5xM6#c$%ekcu;F*(OX(#Yg3W~>u+X+F))Ct sECWCT#z-m - - - - - - \ No newline at end of file diff --git a/res/drawable/gallery_image_background_selector.xml b/res/drawable/gallery_image_background_selector.xml deleted file mode 100644 index 7486fad8..00000000 --- a/res/drawable/gallery_image_background_selector.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - - diff --git a/res/drawable/mediapicker_tab_button_background.xml b/res/drawable/mediapicker_tab_button_background.xml deleted file mode 100644 index 10c4474d..00000000 --- a/res/drawable/mediapicker_tab_button_background.xml +++ /dev/null @@ -1,18 +0,0 @@ - - - diff --git a/res/layout/gallery_grid_item_view.xml b/res/layout/gallery_grid_item_view.xml deleted file mode 100644 index f8f39208..00000000 --- a/res/layout/gallery_grid_item_view.xml +++ /dev/null @@ -1,103 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/res/layout/mediapicker_audio_chooser.xml b/res/layout/mediapicker_audio_chooser.xml deleted file mode 100644 index 795d2f85..00000000 --- a/res/layout/mediapicker_audio_chooser.xml +++ /dev/null @@ -1,96 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/res/layout/mediapicker_camera_chooser.xml b/res/layout/mediapicker_camera_chooser.xml deleted file mode 100644 index 27d26bd0..00000000 --- a/res/layout/mediapicker_camera_chooser.xml +++ /dev/null @@ -1,150 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/res/layout/mediapicker_contact_chooser.xml b/res/layout/mediapicker_contact_chooser.xml deleted file mode 100644 index dcace4f5..00000000 --- a/res/layout/mediapicker_contact_chooser.xml +++ /dev/null @@ -1,61 +0,0 @@ - - - - - - - - - - - - - - - diff --git a/res/layout/mediapicker_fragment.xml b/res/layout/mediapicker_fragment.xml deleted file mode 100644 index 2e414bde..00000000 --- a/res/layout/mediapicker_fragment.xml +++ /dev/null @@ -1,34 +0,0 @@ - - - - - - - - diff --git a/res/layout/mediapicker_gallery_chooser.xml b/res/layout/mediapicker_gallery_chooser.xml deleted file mode 100644 index b4f3c010..00000000 --- a/res/layout/mediapicker_gallery_chooser.xml +++ /dev/null @@ -1,44 +0,0 @@ - - - - - - - \ No newline at end of file diff --git a/res/layout/mediapicker_location_container.xml b/res/layout/mediapicker_location_container.xml deleted file mode 100644 index 6f476d3d..00000000 --- a/res/layout/mediapicker_location_container.xml +++ /dev/null @@ -1,31 +0,0 @@ - - - - - - diff --git a/res/layout/mediapicker_tab_button.xml b/res/layout/mediapicker_tab_button.xml deleted file mode 100644 index 353c871b..00000000 --- a/res/layout/mediapicker_tab_button.xml +++ /dev/null @@ -1,24 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/res/menu/gallery_picker_menu.xml b/res/menu/gallery_picker_menu.xml deleted file mode 100644 index 428f1e73..00000000 --- a/res/menu/gallery_picker_menu.xml +++ /dev/null @@ -1,34 +0,0 @@ - - -

- - - - - diff --git a/res/values-af/strings.xml b/res/values-af/strings.xml index 901bd27a..7c57a7cd 100644 --- a/res/values-af/strings.xml +++ b/res/values-af/strings.xml @@ -37,15 +37,7 @@ "Gereeldes" "Alle kontakte" "Stuur na %s" - "Neem foto\'s of video" - "Kies prente van hierdie toestel af" - "Neem oudio op" "Kies foto" - "Die media is gekies." - "Die media is nie gekies nie." - "%d gekies" - "prent %1$tB %1$te %1$tY %1$tl %1$tM %1$tp" - "prent" "Neem oudio op" "Deel" "Nou net" @@ -138,9 +130,6 @@ "Begin" "Kamera nie beskikbaar nie" - "Kamera nie beskikbaar nie" - "Video-opname nie beskikbaar nie" - "Kan nie media stoor nie" "Kan nie foto neem nie" "Terug" "Geargiveer" @@ -163,13 +152,10 @@ "Vee uit" "Kanselleer" "Aan" - "Kies verskeie prente" - "Bevestig keuse" "+%d" "Kan nie oudio opneem nie. Probeer weer." "Kan nie oudio speel nie. Probeer weer." "Kon nie oudio stoor nie. Probeer weer." - "Raak en hou" ", " " " ": " @@ -218,14 +204,7 @@ "Kon nie boodskap stuur nie. Raak om weer te probeer." "Gesprek met %s" "Vee onderwerp uit" - "Neem video op" - "Vang \'n stilprent vas" - "Neem foto" - "Begin video opneem" - "Skakel na volskerm-kamera oor" "Wissel tussen voorste en agterste kamera" - "Staak opname en heg video aan" - "Stop video-opname" "Foto\'s uit Boodskappe" %d foto\'s is na \"%s\"-album gestoor @@ -410,7 +389,6 @@ "Geblokkeerde kontakte" "DEBLOKKEER" "Geblokkeerde kontakte" - "Kies prent uit dokumentbiblioteek" "Stuur tans boodskap" "Boodskap gestuur" "Sellulêre data is afgeskakel. Gaan jou instellings na." diff --git a/res/values-am/strings.xml b/res/values-am/strings.xml index bea9e6fe..4852f389 100644 --- a/res/values-am/strings.xml +++ b/res/values-am/strings.xml @@ -37,15 +37,7 @@ "ተደጋጋሚዎች" "ሁሉም ዕውቂያዎች" "ወደ %s ላክ" - "ስዕሎችን አንሳ ወይም ቪዲዮ ቅረጽ" - "ከዚህ መሣሪያ ላይ ምስሎችን ይምረጡ" - "ተሰሚ ቅረጽ" "ፎቶ ይምረጡ" - "ሚዲያው ተመርጧል።" - "ሚዲያው አልተመረጠም።" - "%d ተመርጠዋል" - "ምስል %1$tB %1$te %1$tY %1$tl %1$tM %1$tp" - "ምስል" "ተሰሚ ቅረጽ" "አጋራ" "ገና አሁን" @@ -138,9 +130,6 @@ "ጀምር" "ካሜራ ሊገኝ አይችልም" - "ካሜራ ሊገኝ አይችልም" - "የቪዲዮ ቀረጻ ሊገኝ አይችልም" - "ማህደረ መረጃን ማስቀመጥ አይቻልም" "ስዕል ማንሳት አልተቻለም" "ተመለስ" "በማህደር ተቀምጧል" @@ -163,13 +152,10 @@ "ሰርዝ" "ይቅር" "ለ" - "በርካታ ምስሎችን ምረጥ" - "ምርጫ አረጋግጥ" "+%d" "ድምፅን መቅዳት አልተቻለም። እንደገና ይሞክሩ።" "ድምፅን ማጫወት አልተቻለም። እንደገና ይሞክሩ።" "ድምፅን ማስቀመጥ አልተቻለም። እንደገና ይሞክሩ።" - "ነካ ያድርጉ እና ይያዙ" "፣ " " " "፦ " @@ -218,14 +204,7 @@ "ያልተሳካ መልዕክት። ዳግም ለመሞከር ይንኩ።" "ከ%s ጋር ውይይት" "ርዕሰ ጉዳይ ሰርዝ" - "ቪዲዮ ቅረጽ" - "የቆመ ምስል አንሳ" - "ፎቶ አንሳ" - "ቪዲዮ መቅረጽ ጀምር" - "ወደ የሙሉ ማያ ገጽ ካሜራ ቀይር" "በፊት እና ኋላ ካሜራ መካከል ቀያይር" - "መቅረጽ አቁም እና ቪዲዮ አያይዝ" - "ቪዲዮ መቅዳትን አቁም" "ፎቶዎችን በመልዕክት መላክ" %d ፎቶዎች ወደ የ«%s» አልበም ተቀምጠዋል @@ -410,7 +389,6 @@ "የታገዱ እውቂያዎች" "እገዳ አንሳ" "የታገዱ እውቂያዎች" - "ከሰነዶች ቤተመዛግብት ምስል ይምረጡ" "መልዕክት በመላክ ላይ" "መልዕክት ተልኳል" "የተንቀሳቃሽ ስልክ ውሂብ ጠፍቷል። ቅንብሮችዎን ያረጋግጡ።" diff --git a/res/values-ar/strings.xml b/res/values-ar/strings.xml index 1a0f4b90..b43bf535 100644 --- a/res/values-ar/strings.xml +++ b/res/values-ar/strings.xml @@ -37,15 +37,7 @@ "من يتم الاتصال بهم بشكل متكرر" "كل جهات الاتصال" "إرسال إلى %s" - "التقاط الصور أو الفيديو" - "اختيار صور من هذا الجهاز" - "تسجيل الصوت" "اختيار صورة" - "تم تحديد الوسائط." - "تم إلغاء تحديد الوسائط." - "تم تحديد %d" - "صورة %1$tB %1$te %1$tY %1$tl %1$tM %1$tp" - "صورة" "تسجيل الصوت" "المشاركة" "للتو" @@ -174,9 +166,6 @@ "بدء" "لا تتوفر كاميرا" - "لا تتوفر كاميرا" - "ميزة التقاط الفيديو غير متاحة" - "يتعذر حفظ ملف الوسائط" "لا يمكن التقاط الصورة" "رجوع" "المؤرشفة" @@ -203,13 +192,10 @@ "حذف" "إلغاء" "إلى" - "تحديد صور متعددة" - "تأكيد الاختيار" "+%d" "يتعذر تسجيل الصوت. أعد المحاولة." "يتعذر تشغيل الصوت. أعد المحاولة." "تعذر حفظ الصوت. أعد المحاولة." - "اللمس مع الاستمرار" "، " " " ": " @@ -266,14 +252,7 @@ "رسالة تعذر إرسالها. المس لإعادة المحاولة." "محادثة مع %s" "حذف الموضوع" - "التقاط فيديو" - "التقاط صورة ثابتة" - "التقاط صورة" - "بدء تسجيل الفيديو" - "التبديل إلى الكاميرا بملء الشاشة" "التبديل بين الكاميرا الأمامية والخلفية" - "وقف التسجيل وإرفاق مقطع الفيديو" - "إيقاف تسجيل الفيديو" "صور المراسلة" لم يتم حفظ أية صورة (%d) في الألبوم \"%s\" @@ -514,7 +493,6 @@ "جهات الاتصال المحظورة" "إلغاء الحظر" "جهات الاتصال المحظورة" - "اختيار صورة من مكتبة المستندات" "جارٍ إرسال الرسالة" "تم إرسال الرسالة" "تم إيقاف اتصال بيانات الجوّال. تحقق من إعداداتك." diff --git a/res/values-az/strings.xml b/res/values-az/strings.xml index 56b42745..a9dea862 100644 --- a/res/values-az/strings.xml +++ b/res/values-az/strings.xml @@ -37,15 +37,7 @@ "Tez-tez olanlar" "Bütün kontaktlar" "%s ünvanına göndərin" - "Şəkilləri və ya videonu çəkin" - "Bu cihazdan şəkillər seçin" - "Səs yazın" "Foto seçin" - "Media seçildi." - "Media seçilmədi." - "%d seçilib" - "təsvir %1$tB %1$te %1$tY %1$tl %1$tM %1$tp" - "təsvir" "Səs yazın" "Paylaş" "İndicə" @@ -138,9 +130,6 @@ "Başla" "Kamera əlçatmazdır" - "Kamera əlçatmazdır" - "Video çəkiliş əlçatmazdır" - "Medianı yadda saxlamaq olmur" "Şəkil çəkmək mümkün deyil" "Geri" "Arxivləşmiş" @@ -163,13 +152,10 @@ "Sil" "Ləğv et" "Kimə" - "Çox şəkil seçin" - "Seçimi təsdiq edin" "+%d" "Audionu qeydə almaq olmur. Yenidən cəhd edin." "Audionu oxutmaq olmur. Yenidən cəhd edin." "Audionu saxlamaq olmadı. Yenidən cəhd edin." - "Toxunun və saxlayın" ", " " " ": " @@ -218,14 +204,7 @@ "Mesaj göndərilmədi. Təkrar cəhd etmək üçün toxunun." "%s ilə söhbət" "Mövzunu silin" - "Video çəkin" - "Durğun şəkli çəkin" - "Şəkil çəkin" - "Video çəkilişə başlayın" - "Tam ekranlı kameraya keçin" "Ön və arxa kameralar arasında seçim edin" - "Çəkilişi dayandırın və videonu qoşun" - "Video qeydi dayandırın" "Foto mesajlaşma" %d foto \"%s\" albomunda yadda saxlanıldı @@ -410,7 +389,6 @@ "Bloklanmış kontaktlar" "BLOKDAN ÇIXARIN" "Bloklanmış kontaktlar" - "Sənəd kitabxanasından şəkil seçin" "Mesaj göndərilir" "Mesaj göndərildi" "Mobil data deaktivdir. Ayarları yoxlayın." diff --git a/res/values-bg/strings.xml b/res/values-bg/strings.xml index c1b8401e..f95be4d6 100644 --- a/res/values-bg/strings.xml +++ b/res/values-bg/strings.xml @@ -37,15 +37,7 @@ "Често търсени" "Всички контакти" "Изпращане до %s" - "Правете снимки или видеоклипове" - "Избиране на изображения от това устройство" - "Запишете звук" "Избиране на снимка" - "Мултимедийният файл е избран." - "Изборът на мултимедиен файл е премахнат." - "Избрахте %d" - "изображение от %1$tB %1$te %1$tY %1$tl %1$tM %1$tp" - "изображение" "Запис на звук" "Споделяне" "Току-що" @@ -138,9 +130,6 @@ "Стартиране" "Няма достъп до камерата" - "Няма достъп до камерата" - "Функцията за заснемане на видео не е налице" - "Медийният файл не може да се запази" "Не може да се направи снимка" "Назад" "Архивирани" @@ -163,13 +152,10 @@ "Изтриване" "Отказ" "До" - "Избиране на няколко изображения" - "Потвърждаване на избора" "+ %d" "Аудиото не може да се запише. Опитайте отново." "Аудиото не може да се възпроизведе. Опитайте отново." "Аудиото не можа да се запази. Опитайте отново." - "Докоснете и задръжте" ", " " ," ": " @@ -218,14 +204,7 @@ "Неуспешно съобщение. Докоснете, за да опитате отново." "Разговор с/ъс %s" "Изтриване на темата" - "Заснемане на видеоклип" - "Заснемане на кадър" - "Правене на снимка" - "Стартиране на записване на видеоклип" - "Превключване към камера на цял екран" "Превключване между предната и задната камера" - "Спиране на записването и прикачване на видеоклипа" - "Спиране на видеозаписа" "Снимки в Съобщения" %d снимки са запазени в албума „%s @@ -410,7 +389,6 @@ "Блокирани контакти" "ОТБЛОКИРАНЕ" "Блокирани контакти" - "Избиране на изображение от библиотеката с документи" "Съобщението се изпраща" "Съобщението е изпратено" "Мобилните данни са изключени. Проверете настройките си." diff --git a/res/values-bn/strings.xml b/res/values-bn/strings.xml index 96af70b2..0e9d33d5 100644 --- a/res/values-bn/strings.xml +++ b/res/values-bn/strings.xml @@ -37,15 +37,7 @@ "প্রায়শই ব্যবহৃত" "সকল পরিচিতি" "%s এ পাঠান" - "ছবি বা ভিডিও ক্যাপচার করুন" - "এই ডিভাইস থেকে চিত্র নির্বাচন করুন" - "অডিও রেকর্ড করুন" "ফটো চয়ন করুন" - "মিডিয়া নির্বাচিত হয়েছে।" - "মিডিয়া নির্বাচিত হয় নি।" - "%dটি নির্বাচন করা হয়েছে" - "চিত্র %1$tB %1$te %1$tY %1$tl %1$tM %1$tp" - "চিত্র" "অডিও রেকর্ড করুন" "ভাগ করুন" "এইমাত্র" @@ -138,9 +130,6 @@ "আরম্ভ" "ক্যামেরা উপলব্ধ নয়" - "ক্যামেরা উপলব্ধ নয়" - "ভিডিও ক্যাপচার উপলব্ধ নয়" - "মিডিয়া সংরক্ষণ করা যাবে না" "ছবি তুলতে পারবেন না" "ফিরুন" "সংরক্ষণাগারভুক্ত" @@ -163,13 +152,10 @@ "মুছুন" "বাতিল করুন" "প্রাপক" - "একাধিক চিত্র নির্বাচন করুন" - "নির্বাচন নিশ্চিত করুন" "+%dটি" "অডিও রেকর্ড করা যাবে না। আবার চেষ্টা করুন।" "অডিও প্লে করা যাবে না। আবার চেষ্টা করুন।" "অডিও সংরক্ষণ করা যায়নি। আবার চেষ্টা করুন" - "স্পর্শ করুন এবং ধরে রাখুন" ", " " " ": " @@ -218,14 +204,7 @@ "বার্তা পাঠাতে ব্যর্থ হয়েছে। পুনরায় চেষ্টা করতে স্পর্শ করুন।" "%s এর সাথে কথোপকথন" "বিষয় মুছুন" - "ভিডিও ক্যাপচার করুন" - "একটি স্থির চিত্র ক্যাপচার করুন" - "ছবি তুলুন" - "ভিডিও রেকর্ডিং শুরু করুন" - "সম্পূর্ণ স্ক্রীন ক্যামেরাতে স্যুইচ করুন" "সামনে ও পিছনে ক্যামেরার মধ্যে স্যুইচ করুন" - "রেকর্ডিং করা বন্ধ করুন এবং ভিডিও সংযুক্ত করুন" - "ভিডিও রেকর্ডিং বন্ধ করুন" "বার্তাপ্রেরণ ফটোগুলি" %dটি ফটো \"%s\" অ্যালবামে সংরক্ষিত হয়েছে @@ -410,7 +389,6 @@ "অবরুদ্ধ পরিচিতিগুলি" "অবরোধ মুক্ত করুন" "অবরুদ্ধ পরিচিতিগুলি" - "দস্তাবেজ লাইব্রেরি থেকে চিত্র নির্বাচন করুন" "বার্তা পাঠানো হচ্ছে" "বার্তা পাঠানো হয়েছে" "সেলুলার ডেটা বন্ধ আছে। আপনার সেটিংস চেক করুন।" diff --git a/res/values-ca/strings.xml b/res/values-ca/strings.xml index 9f099088..8438e6ed 100644 --- a/res/values-ca/strings.xml +++ b/res/values-ca/strings.xml @@ -37,15 +37,7 @@ "Freqüents" "Tots els contactes" "Envia al %s" - "Captura fotos o vídeos" - "Tria imatges d\'aquest dispositiu." - "Enregistra l\'àudio" "Triar una foto" - "S\'ha seleccionat el fitxer multimèdia." - "No hi ha cap fitxer multimèdia seleccionat." - "Seleccionats: %d" - "imatge: %1$tB %1$te %1$tY %1$tl %1$tM %1$tp" - "imatge" "Enregistra l\'àudio" "Comparteix" "Ara mateix" @@ -138,9 +130,6 @@ "Inicia" "La càmera no està disponible." - "La càmera no està disponible." - "La captura de vídeo no està disponible." - "No es pot desar el fitxer multimèdia." "No es pot fer una foto." "Enrere" "Arxivades" @@ -163,13 +152,10 @@ "Suprimeix" "Cancel·la" "Per a" - "Selecciona diverses imatges" - "Confirma la selecció" "+%d" "No es pot enregistrar l\'àudio. Torna-ho a provar." "No es pot reproduir l\'àudio. Torna-ho a provar." "No s\'ha pogut desar l\'àudio. Torna-ho a provar." - "Toca i mantén premut" ", " " " ": " @@ -218,14 +204,7 @@ "Hi ha hagut un problema amb aquest missatge. Toca per tornar-ho a provar." "Conversa amb %s" "Suprimeix l\'assumpte" - "Captura un vídeo" - "Captura una imatge fixa" - "Fes una foto" - "Comença a enregistrar vídeo" - "Canvia a la càmera de pantalla completa" "Canvia entre la càmera frontal i la posterior" - "Deixa d\'enregistrar i adjunta un vídeo" - "Atura l\'enregistrament de vídeo" "Fotos de missatges" S\'han desat %d fotos a l\'àlbum %s @@ -410,7 +389,6 @@ "Contactes bloquejats" "DESBLOQUEJA" "Contactes bloquejats" - "Tria una imatge de la biblioteca de documents." "S\'està enviant el missatge" "Missatge enviat" "Les dades del telèfon estan desactivades. Comprova\'n la configuració." diff --git a/res/values-cs/strings.xml b/res/values-cs/strings.xml index 7e581127..13171c18 100644 --- a/res/values-cs/strings.xml +++ b/res/values-cs/strings.xml @@ -37,15 +37,7 @@ "Časté kontakty" "Všechny kontakty" "Odeslat na číslo %s" - "Pořídit fotky nebo video" - "Vyberte obrázky z tohoto zařízení." - "Nahrát zvuk" "Vybrat fotku" - "Médium je vybráno." - "Výběr média je zrušen." - "Vybráno: %d" - "obrázek %1$tB %1$te %1$tY %1$tl %1$tM %1$tp" - "obrázek" "Nahrávání zvuku" "Sdílet" "Právě teď" @@ -156,9 +148,6 @@ "Spustit" "Fotoaparát není k dispozici" - "Fotoaparát není k dispozici." - "Záznam videa není k dispozici." - "Média nelze uložit." "Nelze pořídit fotku" "Zpět" "Archivováno" @@ -183,13 +172,10 @@ "Smazat" "Zrušit" "Komu" - "Vybrat několik obrázků" - "Potvrďte výběr" "+%d" "Zvuk nelze zaznamenat. Zkuste to znovu." "Zvuk nelze přehrát. Zkuste to znovu." "Zvuk nelze uložit. Zkuste to znovu." - "Dotyk s podržením" ", " " " ": " @@ -242,14 +228,7 @@ "Neúspěšná zpráva. Klepnutím pokus opakujte." "Konverzace s uživateli %s" "Smazat předmět" - "Natočit video" - "Pořídit statický snímek" - "Vyfotit" - "Začít natáčet video" - "Přepnout na fotoaparát na celou obrazovku" "Přepnout mezi předním a zadním fotoaparátem" - "Zastavit nahrávání a připojit video" - "Zastavit záznam videa" "Fotky z aplikace SMS a MMS" %d fotky byly uloženy do alba %s. @@ -462,7 +441,6 @@ "Blokované kontakty" "ODBLOKOVAT" "Blokované kontakty" - "Vyberte obrázek z knihovny dokumentů." "Odesílání zprávy" "Zpráva byla odeslána" "Mobilní datové přenosy jsou vypnuty. Zkontrolujte svá nastavení." diff --git a/res/values-da/strings.xml b/res/values-da/strings.xml index 6d441da4..a0c0ae2a 100644 --- a/res/values-da/strings.xml +++ b/res/values-da/strings.xml @@ -37,15 +37,7 @@ "Mest brugte" "Alle kontaktpersoner" "Send til %s" - "Optag billeder eller video" - "Vælg billeder fra denne enhed" - "Optag lyd" "Vælg billede" - "Medierne er valgt." - "Medierne er fravalgt." - "Der er valgt %d" - "billede %1$tB %1$te %1$tY %1$tl %1$tM %1$tp" - "billede" "Optag lyd" "Del" "Lige nu" @@ -138,9 +130,6 @@ "Start" "Kameraet er ikke tilgængeligt" - "Kameraet er ikke tilgængeligt" - "Videooptagelse er ikke tilgængelig" - "Medie kan ikke gemmes" "Der kan ikke tages billeder" "Tilbage" "Arkiveret" @@ -163,13 +152,10 @@ "Slet" "Annuller" "Til" - "Vælg flere billeder" - "Bekræft valg" "+ %d" "Der kan ikke optages lyd. Prøv igen." "Lyden kan ikke afspilles. Prøv igen." "Lyden kunne ikke gemmes. Prøv igen." - "Tryk og hold nede" ", " " " ": " @@ -218,14 +204,7 @@ "Mislykket besked. Tryk for at prøve igen." "Samtale med %s" "Slet emne" - "Optag video" - "Tag et stillbillede" - "Tag et billede" - "Start videooptagelse" - "Skift til kamera i fuld skærm" "Skift mellem front- og bagudvendt kamera" - "Stop optagelsen, og vedhæft video" - "Stands optagelse af video" "Billeder i Beskeder" %d billeder blev gemt i albummet \"%s\" @@ -410,7 +389,6 @@ "Blokerede kontakter" "OPHÆV BLOKERING" "Blokerede kontakter" - "Vælg billede fra dokumentbiblioteket" "Sender besked" "Beskeden er sendt" "Mobildata er slået fra. Kontrollér dine indstillinger." diff --git a/res/values-de/strings.xml b/res/values-de/strings.xml index 4537b8bc..9f04be87 100644 --- a/res/values-de/strings.xml +++ b/res/values-de/strings.xml @@ -37,15 +37,7 @@ "Häufig genutzte Kontakte" "Alle Kontakte" "An %s senden" - "Bilder oder Videos aufnehmen" - "Bilder von diesem Gerät auswählen" - "Audio aufzeichnen" "Foto auswählen" - "Die Medien sind ausgewählt." - "Die Auswahl der Medien ist aufgehoben." - "%d ausgewählt" - "Bild: %1$tB %1$te %1$tY %1$tl %1$tM %1$tp" - "Bild" "Audio aufzeichnen" "Teilen" "Gerade eben" @@ -138,9 +130,6 @@ "Starten" "Kamera nicht verfügbar" - "Kamera nicht verfügbar" - "Videoaufnahme nicht verfügbar" - "Medien können nicht gespeichert werden." "Keine Bildaufnahme möglich" "Zurück" "Archiviert" @@ -163,13 +152,10 @@ "Löschen" "Abbrechen" "An" - "Mehrere Bilder auswählen" - "Auswahl bestätigen" "+%d" "Die Audioaufzeichnung ist nicht möglich. Bitte versuchen Sie es erneut." "Die Audiowiedergabe ist nicht möglich. Bitte versuchen Sie es erneut." "Die Audionachricht konnte nicht gespeichert werden. Bitte versuchen Sie es erneut." - "Berühren und halten" ", " " " ": " @@ -218,14 +204,7 @@ "Fehler bei Nachricht. Zum Wiederholen tippen." "Unterhaltung mit %s" "Betreff löschen" - "Video aufnehmen" - "Standbild aufnehmen" - "Bild aufnehmen" - "Videoaufnahme starten" - "Zur Vollbildkamera wechseln" "Zwischen Kamera auf Vorder- und Rückseite wechseln" - "Aufnahme beenden und Video anhängen" - "Videoaufzeichnung stoppen" "Fotos in der SMS/MMS App" %d Fotos im Album \"%s\" gespeichert @@ -410,7 +389,6 @@ "Blockierte Kontakte" "Blockierung aufheben" "Blockierte Kontakte" - "Bild aus der Dokumentenbibliothek auswählen" "Nachricht wird gesendet" "Nachricht gesendet" "Mobilfunkdaten sind deaktiviert. Bitte überprüfen Sie Ihre Einstellungen." diff --git a/res/values-el/strings.xml b/res/values-el/strings.xml index d74a84b1..49befda5 100644 --- a/res/values-el/strings.xml +++ b/res/values-el/strings.xml @@ -37,15 +37,7 @@ "Συχνή επικοινωνία" "Όλες οι επαφές" "Αποστολή σε %s" - "Λήψη εικόνων ή βίντεο" - "Επιλέξτε εικόνες από αυτήν τη συσκευή" - "Ηχογράφηση" "Επιλογή φωτογραφίας" - "Το μέσο είναι επιλεγμένο." - "Το μέσο δεν είναι επιλεγμένο." - "%d επιλεγμένα" - "εικόνα %1$tB %1$te %1$tY %1$tl %1$tM %1$tp" - "εικόνα" "Ηχογράφηση" "Κοινή χρήση" "Μόλις τώρα" @@ -138,9 +130,6 @@ "Έναρξη" "Η φωτογραφική μηχανή δεν είναι διαθέσιμη" - "Η φωτογραφική μηχανή δεν είναι διαθέσιμη" - "Η εγγραφή βίντεο δεν είναι διαθέσιμη" - "Δεν είναι δυνατή η αποθήκευση πολυμέσων" "Δεν είναι δυνατή η λήψη φωτογραφίας" "Πίσω" "Αρχειοθετημένο" @@ -163,13 +152,10 @@ "Διαγραφή" "Ακύρωση" "Προς" - "Επιλογή πολλαπλών εικόνων" - "Επιβεβαίωση επιλογής" "+%d" "Δεν είναι δυνατή η αποθήκευση ήχου. Δοκιμάστε ξανά." "Δεν είναι δυνατή η αναπαραγωγή ήχου. Δοκιμάστε ξανά." "Δεν ήταν δυνατή η αποθήκευση ήχου. Δοκιμάστε ξανά." - "Άγγιγμα & κράτημα" ", " " " ": " @@ -218,14 +204,7 @@ "Αποτυχία μηνύματος. Αγγίξτε για επανάληψη." "Συνομιλία με %s" "Διαγραφή θέματος" - "Λήψη βίντεο" - "Τραβήξτε μια στατική εικόνα" - "Τραβήξτε μια φωτογραφία" - "Έναρξη εγγραφής βίντεο" - "Εναλλαγή σε κάμερα πλήρους οθόνης" "Εναλλαγή μεταξύ μπροστινής και της πίσω φωτογραφικής μηχανής" - "Διακοπή εγγραφής και επισύναψη βίντεο" - "Διακοπή εγγραφής βίντεο" "Φωτογραφίες Ανταλλαγής μηνυμάτων" %d φωτογραφίες αποθηκεύτηκαν στο λεύκωμα \"%s\" @@ -410,7 +389,6 @@ "Αποκλεισμένες επαφές" "ΚΑΤΑΡΓΗΣΗ ΑΠΟΚΛΕΙΣΜΟΥ" "Αποκλεισμένες επαφές" - "Επιλέξτε μια εικόνα από τη βιβλιοθήκη εγγράφων" "Αποστολή μηνύματος" "Το μήνυμα εστάλη" "Τα δεδομένα κινητής τηλεφωνίας έχουν απενεργοποιηθεί. Ελέγξτε τις ρυθμίσεις σας." diff --git a/res/values-en-rAU/strings.xml b/res/values-en-rAU/strings.xml index 3a642fbc..33c6f0bb 100644 --- a/res/values-en-rAU/strings.xml +++ b/res/values-en-rAU/strings.xml @@ -37,15 +37,7 @@ "Frequents" "All contacts" "Send to %s" - "Capture pictures or video" - "Choose images from this device" - "Record audio" "Choose photo" - "The media is selected." - "The media is unselected." - "%d selected" - "image %1$tB %1$te %1$tY %1$tl %1$tM %1$tp" - "image" "Record audio" "Share" "Just now" @@ -138,9 +130,6 @@ "Start" "Camera not available" - "Camera not available" - "Video capture not available" - "Can\'t save media" "Can\'t take picture" "Back" "Archived" @@ -163,13 +152,10 @@ "Delete" "Cancel" "To" - "Select multiple images" - "Confirm selection" "+%d" "Can\'t record audio. Try again." "Can\'t play audio. Try again." "Couldn\'t save audio. Try again." - "Touch & hold" ", " " " ": " @@ -218,14 +204,7 @@ "Failed message. Touch to retry." "Conversation with %s" "Delete subject" - "Capture video" - "Capture a still image" - "Take picture" - "Start recording video" - "Switch to full screen camera" "Switch between front and back camera" - "Stop recording and attach video" - "Stop recording video" "Messaging photos" %d photos saved to \"%s\" album @@ -410,7 +389,6 @@ "Blocked contacts" "UNBLOCK" "Blocked contacts" - "Choose image from document library" "Sending message" "Message sent" "Mobile data is turned off. Check your settings." diff --git a/res/values-en-rGB/strings.xml b/res/values-en-rGB/strings.xml index 3a642fbc..33c6f0bb 100644 --- a/res/values-en-rGB/strings.xml +++ b/res/values-en-rGB/strings.xml @@ -37,15 +37,7 @@ "Frequents" "All contacts" "Send to %s" - "Capture pictures or video" - "Choose images from this device" - "Record audio" "Choose photo" - "The media is selected." - "The media is unselected." - "%d selected" - "image %1$tB %1$te %1$tY %1$tl %1$tM %1$tp" - "image" "Record audio" "Share" "Just now" @@ -138,9 +130,6 @@ "Start" "Camera not available" - "Camera not available" - "Video capture not available" - "Can\'t save media" "Can\'t take picture" "Back" "Archived" @@ -163,13 +152,10 @@ "Delete" "Cancel" "To" - "Select multiple images" - "Confirm selection" "+%d" "Can\'t record audio. Try again." "Can\'t play audio. Try again." "Couldn\'t save audio. Try again." - "Touch & hold" ", " " " ": " @@ -218,14 +204,7 @@ "Failed message. Touch to retry." "Conversation with %s" "Delete subject" - "Capture video" - "Capture a still image" - "Take picture" - "Start recording video" - "Switch to full screen camera" "Switch between front and back camera" - "Stop recording and attach video" - "Stop recording video" "Messaging photos" %d photos saved to \"%s\" album @@ -410,7 +389,6 @@ "Blocked contacts" "UNBLOCK" "Blocked contacts" - "Choose image from document library" "Sending message" "Message sent" "Mobile data is turned off. Check your settings." diff --git a/res/values-en-rIN/strings.xml b/res/values-en-rIN/strings.xml index 3a642fbc..33c6f0bb 100644 --- a/res/values-en-rIN/strings.xml +++ b/res/values-en-rIN/strings.xml @@ -37,15 +37,7 @@ "Frequents" "All contacts" "Send to %s" - "Capture pictures or video" - "Choose images from this device" - "Record audio" "Choose photo" - "The media is selected." - "The media is unselected." - "%d selected" - "image %1$tB %1$te %1$tY %1$tl %1$tM %1$tp" - "image" "Record audio" "Share" "Just now" @@ -138,9 +130,6 @@ "Start" "Camera not available" - "Camera not available" - "Video capture not available" - "Can\'t save media" "Can\'t take picture" "Back" "Archived" @@ -163,13 +152,10 @@ "Delete" "Cancel" "To" - "Select multiple images" - "Confirm selection" "+%d" "Can\'t record audio. Try again." "Can\'t play audio. Try again." "Couldn\'t save audio. Try again." - "Touch & hold" ", " " " ": " @@ -218,14 +204,7 @@ "Failed message. Touch to retry." "Conversation with %s" "Delete subject" - "Capture video" - "Capture a still image" - "Take picture" - "Start recording video" - "Switch to full screen camera" "Switch between front and back camera" - "Stop recording and attach video" - "Stop recording video" "Messaging photos" %d photos saved to \"%s\" album @@ -410,7 +389,6 @@ "Blocked contacts" "UNBLOCK" "Blocked contacts" - "Choose image from document library" "Sending message" "Message sent" "Mobile data is turned off. Check your settings." diff --git a/res/values-es-rUS/strings.xml b/res/values-es-rUS/strings.xml index f474514c..19a3e04a 100644 --- a/res/values-es-rUS/strings.xml +++ b/res/values-es-rUS/strings.xml @@ -37,15 +37,7 @@ "Frecuentes" "Todos los contactos" "Enviar a %s" - "Capturar imágenes o video" - "Seleccionar imágenes de este dispositivo" - "Grabar audio" "Elegir foto" - "El medio está seleccionado." - "Los medios no están seleccionados." - "Seleccionado: %d" - "imagen %1$tB %1$te %1$tY %1$tl %1$tM %1$tp" - "imagen" "Grabar audio" "Compartir" "Recién" @@ -138,9 +130,6 @@ "Iniciar" "Cámara no disponible" - "Cámara no disponible" - "Captura de video no disponible" - "No se puede guardar el contenido multimedia." "No se puede tomar la foto." "Atrás" "Archivadas" @@ -163,13 +152,10 @@ "Eliminar" "Cancelar" "Para" - "Seleccionar varias imágenes" - "Confirmar selección" "%d más" "No se puede grabar el audio. Vuelve a intentarlo." "No se puede reproducir el audio. Vuelve a intentarlo." "No se pudo guardar el audio. Vuelve a intentarlo." - "Mantener presionado" ", " " " ": " @@ -218,14 +204,7 @@ "Se produjo un error en el mensaje. Toca para volver a intentarlo." "Conversación con %s" "Eliminar asunto" - "Capturar video" - "Capturar una imagen fija" - "Tomar fotografía" - "Iniciar grabación de video" - "Cambiar a cámara en pantalla completa" "Alternar entre cámara frontal y trasera" - "Detener la grabación y adjuntar video" - "Detener la grabación de video" "Fotos de Centro de Mensajes" Se guardaron %d fotos en el álbum \"%s\" @@ -410,7 +389,6 @@ "Contactos bloqueados" "DESBLOQUEAR" "Contactos bloqueados" - "Seleccionar imagen de la galería de documentos" "Enviando el mensaje" "Se envió el mensaje." "Los datos móviles están desactivados. Comprueba la configuración." diff --git a/res/values-es/strings.xml b/res/values-es/strings.xml index 98e0a1d6..7b0b87d8 100644 --- a/res/values-es/strings.xml +++ b/res/values-es/strings.xml @@ -37,15 +37,7 @@ "Frecuentes" "Todos los contactos" "Enviar a %s" - "Capturar imágenes o vídeo" - "Seleccionar imágenes de este dispositivo" - "Grabar audio" "Elegir foto" - "Contenido multimedia seleccionado." - "Contenido multimedia no seleccionado." - "Seleccionado: %d" - "imagen del %1$tB %1$te %1$tY %1$tl %1$tM %1$tp" - "imagen" "Grabar audio" "Compartir" "Ahora mismo" @@ -138,9 +130,6 @@ "Iniciar" "Cámara no disponible" - "Cámara no disponible" - "Captura de vídeo no disponible" - "No se puede guardar el archivo multimedia" "No se pueden hacer fotos" "Atrás" "Archivado" @@ -163,13 +152,10 @@ "Eliminar" "Cancelar" "Para" - "Seleccionar varias imágenes" - "Confirmar selección" "+%d" "No se puede grabar el audio. Vuelve a intentarlo." "No se puede reproducir el audio. Vuelve a intentarlo." "No se ha podido guardar el audio. Vuelve a intentarlo." - "Mantener pulsado" ", " " " ": " @@ -218,14 +204,7 @@ "Error del mensaje. Toca para volver a intentarlo." "Conversación con %s" "Eliminar asunto" - "Capturar vídeo" - "Capturar una imagen fija" - "Hacer foto" - "Iniciar grabación de vídeo" - "Cambiar a cámara en pantalla completa" "Cambiar de la cámara frontal a la trasera" - "Dejar de grabar y adjuntar vídeo" - "Detener grabación de vídeo" "Fotos de Mensajes" %d fotos guardadas en el álbum %s @@ -410,7 +389,6 @@ "Contactos bloqueados" "DESBLOQUEAR" "Contactos bloqueados" - "Selecciona imagen de la biblioteca de documentos" "Enviando mensaje" "Mensaje enviado" "Los datos móviles están desactivados. Comprueba los ajustes." diff --git a/res/values-et/strings.xml b/res/values-et/strings.xml index e222a3ea..5a24faaf 100644 --- a/res/values-et/strings.xml +++ b/res/values-et/strings.xml @@ -37,15 +37,7 @@ "Sagedased" "Kõik kontaktid" "Saada numbrile %s" - "Jäädvustage pilte või videoid" - "Kujutiste valimine sellest seadmest" - "Heli salvestamine" "Foto valimine" - "Meedia on valitud." - "Meediat pole valitud." - "%d on valitud" - "pilt kuupäevaga %1$tB %1$te %1$tY %1$tl %1$tM %1$tp" - "pilt" "Heli salvestamine" "Jagamine" "Hetk tagasi" @@ -138,9 +130,6 @@ "Käivita" "Kaamera pole saadaval" - "Kaamera pole saadaval" - "Video jäädvustamine pole saadaval" - "Meediat ei saa salvestada" "Pilti ei saa jäädvustada" "Tagasi" "Arhiivitud" @@ -163,13 +152,10 @@ "Kustuta" "Tühista" "Sihtkohta" - "Mitme kujutise valimine" - "Valiku kinnitamine" "ja veel %d" "Heli ei õnnestu salvestada. Proovige uuesti." "Heli ei saa esitada. Proovige uuesti." "Heli ei õnnestunud salvestada. Proovige uuesti." - "Puudutage pikalt" ", " " " ": " @@ -218,14 +204,7 @@ "Sõnumi saatmine ebaõnnestus. Puudutage uuesti proovimiseks." "Vestlus osalejatega %s" "Teema kustutamine" - "Video jäädvustamine" - "Foto jäädvustamine" - "Pildistamine" - "Video salvestamise alustamine" - "Lülitamine täisekraaniga kaamerale" "Esi- ja tagakaamera vahetamine" - "Salvestamise peatamine ja video lisamine" - "Lõpeta video salvestamine" "Sõnumside fotod" %d fotot salvestati albumisse „%s @@ -410,7 +389,6 @@ "Blokeeritud kontaktid" "DEBLOKEERIMINE" "Blokeeritud kontaktid" - "Dokumendikogust kujutise valimine" "Sõnumi saatmine" "Sõnum on saadetud" "Mobiilne andmeside on välja lülitatud. Kontrollige seadeid." diff --git a/res/values-eu/strings.xml b/res/values-eu/strings.xml index 17036814..9df7b987 100644 --- a/res/values-eu/strings.xml +++ b/res/values-eu/strings.xml @@ -37,15 +37,7 @@ "Maiz erabilitakoak" "Kontaktu guztiak" "Bidali hona: %s" - "Atera irudiak edo grabatu bideoa" - "Aukeratu gailu honetako irudiak" - "Grabatu audioa" "Aukeratu argazkia" - "Euskarria hautatu da." - "Euskarria desautatu da." - "%d hautatuta" - "Irudiaren data: %1$tB %1$te %1$tY %1$tl %1$tM %1$tp" - "irudia" "Grabatu audioa" "Partekatu" "Oraintxe" @@ -138,9 +130,6 @@ "Hasi" "Kamera ez dago erabilgarri" - "Kamera ez dago erabilgarri" - "Bideo-grabaketa ez dago erabilgarri" - "Ezin da gorde multimedia-elementua" "Ezin da atera argazkia" "Atzera" "Artxibatutakoak" @@ -163,13 +152,10 @@ "Ezabatu" "Utzi" "Hartzailea" - "Hautatu hainbat irudi" - "Berretsi hautapena" "+%d" "Ezin da grabatu audioa. Saiatu berriro." "Ezin da erreproduzitu audioa. Saiatu berriro." "Ezin izan da gorde audioa. Saiatu berriro." - "Eduki ukituta" ", " " " ": " @@ -218,14 +204,7 @@ "Mezuak huts egin du. Ukitu berriro saiatzeko." "Elkarrizketa erabiltzaile hauekin (%s)" "Ezabatu gaia" - "Grabatu bideoa" - "Atera irudi finkoa" - "Atera argazkia" - "Hasi bideoa grabatzen" - "Aldatu pantaila osoko kamerara" "Aldatu aurreko eta atzeko kameren artean" - "Utzi grabatzeari eta erantsi bideoa" - "Utzi bideoa grabatzeari" "Mezuak aplikazioko argazkiak" %d argazki gorde dira \"%s\" albumean @@ -410,7 +389,6 @@ "Blokeatutako kontaktuak" "DESBLOKEATU" "Blokeatutako kontaktuak" - "Aukeratu irudia dokumentuen liburutegitik" "Mezua bidaltzen" "Mezua bidali da" "Mugikorreko datuak desaktibatuta daude. Egiaztatu ezarpenak." diff --git a/res/values-fa/strings.xml b/res/values-fa/strings.xml index a1bf9d87..387e8ede 100644 --- a/res/values-fa/strings.xml +++ b/res/values-fa/strings.xml @@ -37,15 +37,7 @@ "مکرر" "همه مخاطبین" "ارسال به %s" - "گرفتن عکس یا ویدیو" - "انتخاب تصاویر از این دستگاه" - "ضبط صدا" "انتخاب عکس" - "رسانه انتخاب شده است." - "رسانه انتخاب نشده است." - "%d مورد انتخاب شد" - "تاریخ تصویر %1$tB %1$te %1$tY %1$tl %1$tM %1$tp" - "تصویر" "ضبط صدا" "اشتراک‌گذاری" "هم‌اکنون" @@ -138,9 +130,6 @@ "شروع" "دوربین در دسترس نیست" - "دوربین در دسترس نیست" - "ضبط ویدیو در دسترس نیست" - "رسانه ذخیره نمی‌شود" "عکس نمی‌گیرد" "برگشت" "بایگانی شده" @@ -163,13 +152,10 @@ "حذف" "لغو" "به" - "انتخاب چند تصویر" - "تأیید انتخاب" "+%d" "صدا ضبط نمی‌شود. دوباره امتحان کنید." "صدا پخش نمی‌شود. دوباره امتحان کنید." "صدا ذخیره نشد. دوباره امتحان کنید." - "لمس کنید و نگه‌دارید" "، " " " ": " @@ -218,14 +204,7 @@ "پیام ناموفق. برای امتحان مجدد، لمس کنید." "مکالمه با %s" "حذف موضوع" - "فیلم‌برداری" - "ضبط تصویر ثابت" - "عکس گرفتن" - "شروع فیلم‌برداری" - "تغییر به دوربین تمام صفحه" "جابه‌جایی بین دوربین جلو و عقب" - "توقف ضبط و پیوست ویدیو" - "توقف ضبط ویدیو" "عکس‌های «پیام‌رسانی»" %d عکس در آلبوم «%s» ذخیره شد @@ -410,7 +389,6 @@ "مخاطبین مسدود شده" "رفع انسداد" "مخاطبین مسدود شده" - "انتخاب تصویر از کتابخانه سند" "در حال ارسال پیام" "پیام ارسال شد" "داده شبکه تلفن همراه خاموش است. تنظیماتتان را بررسی کنید." diff --git a/res/values-fi/strings.xml b/res/values-fi/strings.xml index a07825b4..1a4c76ba 100644 --- a/res/values-fi/strings.xml +++ b/res/values-fi/strings.xml @@ -37,15 +37,7 @@ "Usein käytetyt" "Yhteystiedot" "Lähetä numeroon %s" - "Tallenna kuva tai video" - "Valitse kuvia tästä laitteesta" - "Tallenna ääntä" "Valitse valokuva" - "Media on valittu." - "Median valinta on poistettu." - "%d valittu" - "kuva %1$tB %1$te %1$tY %1$tl %1$tM %1$tp" - "kuva" "Tallenna ääntä" "Jaa" "Juuri nyt" @@ -138,9 +130,6 @@ "Käynnistä" "Kamera ei käytettävissä" - "Kamera ei käytettävissä" - "Videon kaappaus ei käytettävissä" - "Mediaa ei voi tallentaa" "Kuvan ottaminen ei onnistu." "Edellinen" "Arkistoitu" @@ -163,13 +152,10 @@ "Poista" "Peruuta" "Vastaanottaja" - "Valitse useita kuvia" - "Vahvista valinta" "+ %d" "Ääntä ei voi tallentaa. Yritä uudelleen." "Ääntä ei voi toistaa. Yritä uudelleen." "Ääntä ei voitu tallentaa. Yritä uudelleen." - "Kosketa pitkään" ", " " " ": " @@ -218,14 +204,7 @@ "Viesti epäonnistui. Yritä uudelleen koskettamalla." "Keskustelu henkilöiden %s kanssa" "Poista aihe" - "Tallenna video" - "Ota kuva" - "Ota kuva" - "Aloita videon tallentaminen" - "Siirry koko näytön kameraan" "Vaihda etu- ja takakameran välillä" - "Lopeta tallennus ja liitä video" - "Lopeta videon tallentaminen" "Viestien valokuvat" %d valokuvaa tallennettu albumiin %s @@ -410,7 +389,6 @@ "Estetyt yhteystiedot" "KUMOA ESTO" "Estetyt yhteystiedot" - "Valitse kuva dokumenttikirjastosta" "Lähetetään viestiä" "Viesti lähetetty" "Matkapuhelindata on kytketty pois. Tarkista asetukset." diff --git a/res/values-fr-rCA/strings.xml b/res/values-fr-rCA/strings.xml index a6268326..de6b1933 100644 --- a/res/values-fr-rCA/strings.xml +++ b/res/values-fr-rCA/strings.xml @@ -37,15 +37,7 @@ "Fréquents" "Tous les contacts" "Envoyer à %s" - "Prendre des photos ou filmer une vidéo" - "Choisir des images de cet appareil" - "Enregistrer des fichiers audio" "Choisir une photo" - "Support sélectionné." - "Support désélectionné." - "%d sélectionné(s)" - "Image : %1$tB %1$te %1$tY %1$tl %1$tM %1$tp" - "image" "Enregistrer des fichiers audio" "Partager" "À l\'instant" @@ -138,9 +130,6 @@ "Démarrer" "L\'appareil photo n\'est pas disponible" - "L\'appareil photo n\'est pas disponible" - "L\'enregistrement vidéo n\'est pas disponible" - "Impossible d\'enregistrer le média" "Impossible de prendre la photo." "Précédent" "Archivée" @@ -163,13 +152,10 @@ "Supprimer" "Annuler" "À" - "Sélectionner plusieurs images" - "Confirmer la sélection" "+%d" "Impossible d\'enregistrer l\'audio. Veuillez réessayer." "Impossible de lire l\'audio. Veuillez réessayer." "Impossible d\'enregistrer l\'audio. Veuillez réessayer." - "Maintenez le doigt ici" ", " " " ": " @@ -218,14 +204,7 @@ "Échec du message. Touchez pour réessayer." "Conversation avec %s" "Supprimer l\'objet" - "Filmer une vidéo" - "Capturer une image fixe" - "Prendre une photo" - "Lancer l\'enregistrement vidéo" - "Activer le mode plein écran de l\'appareil photo" "Basculer entre l\'appareil photo avant et arrière" - "Arrêter l\'enregistrement et joindre la vidéo" - "Arrêter l\'enregistrement vidéo" "Photos de Messagerie" %d photo enregistrée dans l\'album « %s » @@ -410,7 +389,6 @@ "Contacts bloqués" "DÉBLOQUER" "Contacts bloqués" - "Choisissez une image dans la bibliothèque de documents" "Envoi du message en cours..." "Message envoyé" "Les données cellulaires sont désactivées. Vérifiez vos paramètres." diff --git a/res/values-fr/strings.xml b/res/values-fr/strings.xml index b9f22af8..27dafaf7 100644 --- a/res/values-fr/strings.xml +++ b/res/values-fr/strings.xml @@ -37,15 +37,7 @@ "Fréquents" "Tous les contacts" "Envoyer au %s" - "Prendre des photos ou filmer une vidéo" - "Sélectionner des images sur cet appareil" - "Enregistrer un fichier audio" "Sélectionner une photo" - "Le support est sélectionné." - "Le support est désélectionné." - "%d élément(s) sélectionné(s)" - "image %1$tB %1$te %1$tY %1$tl %1$tM %1$tp" - "image" "Enregistrer un fichier audio" "Partager" "À l\'instant" @@ -138,9 +130,6 @@ "Démarrer" "Caméra indisponible." - "Caméra indisponible." - "Enregistrement vidéo indisponible." - "Impossible d\'enregistrer le fichier multimédia." "Impossible de prendre la photo." "Retour" "Archivées" @@ -163,13 +152,10 @@ "Supprimer" "Annuler" "À" - "Sélectionner plusieurs images" - "Confirmer la sélection" "+ %d" "Impossible d\'enregistrer l\'audio. Veuillez réessayer." "Impossible de lire l\'audio. Veuillez réessayer." "Impossible d\'enregistrer l\'audio. Veuillez réessayer." - "Appuyez ici de manière prolongée" ", " " " " : " @@ -218,14 +204,7 @@ "Échec du message. Appuyez pour réessayer." "Conversation avec %s" "Supprimer l\'objet" - "Enregistrer une vidéo" - "Capturer une image fixe" - "Prendre une photo" - "Lancer l\'enregistrement vidéo" - "Passer en caméra plein écran" "Passer de la caméra frontale à la caméra arrière, et inversement" - "Arrêter l\'enregistrement et joindre la vidéo" - "Arrêter l\'enregistrement vidéo" "Photos envoyées ou reçues par SMS/MMS" %d photo a été enregistrée dans l\'album \"%s\" @@ -410,7 +389,6 @@ "Contacts bloqués" "DÉBLOQUER" "Contacts bloqués" - "Sélectionner une image dans la bibliothèque de documents" "Envoi du message en cours..." "Message envoyé." "Les données cellulaires sont désactivées. Veuillez vérifier vos paramètres." diff --git a/res/values-gl/strings.xml b/res/values-gl/strings.xml index 8a626c20..17f27acf 100644 --- a/res/values-gl/strings.xml +++ b/res/values-gl/strings.xml @@ -37,15 +37,7 @@ "Frecuentes" "Todos os contactos" "Enviar ao %s" - "Capturar fotos ou vídeo" - "Escoller imaxes deste dispositivo" - "Gravar audio" "Escoller foto" - "O ficheiro multimedia está seleccionado." - "O ficheiro multimedia non está seleccionado." - "%d seleccionado" - "imaxe do %1$tB %1$te %1$tY %1$tl %1$tM %1$tp" - "imaxe" "Gravar audio" "Compartir" "Agora mesmo" @@ -138,9 +130,6 @@ "Iniciar" "A cámara non está dispoñible" - "A cámara non está dispoñible" - "A captura de vídeo non está dispoñible" - "Non se pode gardar o ficheiro multimedia" "Non se pode sacar a foto" "Atrás" "Arquivadas" @@ -163,13 +152,10 @@ "Eliminar" "Cancelar" "Para" - "Seleccionar varias imaxes" - "Confirmar selección" "+%d" "Non se pode gravar o audio. Téntao de novo." "Non se pode reproducir o audio. Téntao de novo." "Non se puido gardar o audio. Téntao de novo." - "Mantén premido" ", " " " ": " @@ -218,14 +204,7 @@ "Erro na mensaxe. Toca para tentalo de novo." "Conversa con %s" "Borrar asunto" - "Capturar vídeo" - "Capturar unha imaxe estática" - "Sacar foto" - "Comezar a gravar vídeo" - "Cambiar á cámara de pantalla completa" "Cambiar entre a cámara dianteira e traseira" - "Deter a gravación e anexar vídeo" - "Deter a gravación de vídeo" "Fotos de Mensaxería" Gardáronse %d fotos no álbum \"%s\" @@ -410,7 +389,6 @@ "Contactos bloqueados" "DESBLOQUEAR" "Contactos bloqueados" - "Escolle unha imaxe da biblioteca de documentos" "Enviando a mensaxe" "Mensaxe enviada" "Os datos móbiles están desactivados. Comproba a túa configuración." diff --git a/res/values-gu/strings.xml b/res/values-gu/strings.xml index 8d2adaf9..1ad13388 100644 --- a/res/values-gu/strings.xml +++ b/res/values-gu/strings.xml @@ -37,15 +37,7 @@ "વારંવાર" "તમામ સંપર્કો" "%s પર મોકલો" - "ચિત્રો અથવા વિડિઓ કેપ્ચર કરો" - "આ ઉપકરણથી છબીઓ પસંદ કરો" - "ઑડિઓ રેકોર્ડ કરો" "ફોટો પસંદ કરો" - "મીડિયા પસંદ થયેલ છે." - "મીડિયા પસંદ કરેલ નથી." - "%d પસંદ કર્યાં" - "છબી %1$tB %1$te %1$tY %1$tl %1$tM %1$tp" - "છબી" "ઑડિઓ રેકોર્ડ કરો" "શેર કરો" "હમણાં જ" @@ -138,9 +130,6 @@ "પ્રારંભ કરો" "કૅમેરો ઉપલબ્ધ નથી" - "કૅમેરો ઉપલબ્ધ નથી" - "વિડિઓ કેપ્ચર ઉપલબ્ધ નથી" - "મીડિયા સાચવી શકતા નથી" "ચિત્ર લઈ શકતા નથી" "પાછળ" "આર્કાઇવ કરેલા" @@ -163,13 +152,10 @@ "કાઢી નાખો" "રદ કરો" "પ્રતિ" - "બહુવિધ છબીઓ પસંદ કરો" - "પસંદગીની પુષ્ટિ કરો" "+%d" "ઑડિઓ રેકોર્ડ કરી શકાતો નથી. ફરીથી પ્રયાસ કરો." "ઑડિઓ ચલાવી શકાતો નથી. ફરીથી પ્રયાસ કરો." "ઑડિઓ સાચવી શકાયો નથી. ફરીથી પ્રયાસ કરો." - "ટચ કરો અને પકડો" ", " " " ": " @@ -218,14 +204,7 @@ "નિષ્ફળ સંદેશ. ફરી પ્રયાસ કરવા ટચ કરો." "%s સાથે વાર્તાલાપ" "વિષય કાઢી નાખો" - "વિડિઓ કેપ્ચર કરો" - "એક સ્થિર છબી કેપ્ચર કરો" - "ફોટો લો" - "વિડિઓ રેકોર્ડિંગ પ્રારંભ કરો" - "પૂર્ણ સ્ક્રીન કૅમેરા પર સ્વિચ કરો" "આગળનાં અને પાછળનાં કૅમેરા વચ્ચે સ્વિચ કરો" - "રેકોર્ડિંગ બંધ કરો અને વિડિઓ જોડો" - "વિડિઓ રેકોર્ડિંગ બંધ કરો" "મેસેજિંગ ફોટા" \"%s\" આલ્બમમાં %d ફોટા સાચવ્યાં @@ -410,7 +389,6 @@ "અવરોધિત કરેલા સંપર્કો" "અનાવરોધિત કરો" "અવરોધિત કરેલા સંપર્કો" - "દસ્તાવેજ લાઇબ્રેરીમાંથી છબી પસંદ કરો" "સંદેશ મોકલી રહ્યાં છીએ" "સંદેશ મોકલ્યો" "સેલ્યુલર ડેટા બંધ છે. તમારી સેટિંગ્સ તપાસો." diff --git a/res/values-hi/strings.xml b/res/values-hi/strings.xml index 7e7a780e..6672c254 100644 --- a/res/values-hi/strings.xml +++ b/res/values-hi/strings.xml @@ -37,15 +37,7 @@ "अक्सर" "सभी संपर्क" "%s पर भेजें" - "चित्र या वीडियो कैप्चर करें" - "इस डिवाइस के चित्र चुनें" - "ऑडियो रिकॉर्ड करें" "फ़ोटो चुनें" - "मीडिया को चुना है." - "मीडिया को नहीं चुना है." - "%d चुने गए" - "चित्र %1$tB %1$te %1$tY %1$tl %1$tM %1$tp" - "चित्र" "ऑडियो रिकॉर्ड करें" "साझा करें" "अभी-अभी" @@ -138,9 +130,6 @@ "प्रारंभ करें" "कैमरा उपलब्ध नहीं है" - "कैमरा उपलब्ध नहीं है" - "वीडियो कैप्‍चर उपलब्‍ध नहीं है" - "मीडिया सहेजा नहीं जा सकता" "चित्र नहीं लिया जा सकता" "पीछे" "संग्रहीत" @@ -163,13 +152,10 @@ "हटाएं" "अभी नहीं" "प्रति" - "एकाधिक चित्र चुनें" - "चयन की दुबारा पूछें" "+%d" "ऑडियो रिकॉर्ड नहीं किया जा सकता. पुनः प्रयास करें." "ऑडियो नहीं चलाया जा सकता. पुन: प्रयास करें." "ऑडियो सहेजा नहीं जा सका. पुनः प्रयास करें." - "स्पर्श करके रखें" ", " " " ": " @@ -218,14 +204,7 @@ "विफल रहा संदेश. पुन: प्रयास करने के लिए स्पर्श करें." "%s के साथ बातचीत" "विषय हटाएं" - "वीडियो कैप्चर करें" - "स्थिर चित्र कैप्चर करें" - "चित्र लें" - "वीडियो रिकॉर्ड करना प्रारंभ करें" - "पूर्ण स्क्रीन कैमरा पर स्विच करें" "सामने और पीछे वाले कैमरे के बीच स्‍विच करें" - "रिकॉर्डिंग रोकें और वीडियो अटैच करें" - "वीडियो रिकॉर्ड करना बंद करें" "संदेश सेवा फ़ोटो" %d फ़ोटो \"%s\" एल्बम में सहेजी गईं @@ -410,7 +389,6 @@ "अवरोधित संपर्क" "अनवरोधित करें" "अवरोधित संपर्क" - "दस्‍तावेज़ लाइब्रेरी से चित्र चुनें" "संदेश भेजा जा रहा है" "संदेश भेजा गया" "सेलुलर डेटा बंद हो गया है. अपनी सेटिंग जांचें." diff --git a/res/values-hr/strings.xml b/res/values-hr/strings.xml index b8bb490b..b0c62b06 100644 --- a/res/values-hr/strings.xml +++ b/res/values-hr/strings.xml @@ -37,15 +37,7 @@ "Često kontaktirani" "Svi kontakti" "Pošalji na %s" - "Snimite fotografije ili videozapis" - "Odaberite slike s ovog uređaja" - "Snimanje zvuka" "Odaberite fotografiju" - "Medij je odabran." - "Odabir medija poništen je." - "Odabrano: %d" - "slika %1$tB %1$te %1$tY %1$tl %1$tM %1$tp" - "slika" "Snimanje zvuka" "Dijeljenje" "Upravo sada" @@ -147,9 +139,6 @@ "Započni" "Fotoaparat nije dostupan" - "Fotoaparat nije dostupan" - "Snimanje videozapisa nije dostupno" - "Medij nije spremljen" "Nije moguće snimiti sliku" "Natrag" "Arhivirano" @@ -173,13 +162,10 @@ "Izbriši" "Odustani" "Odredište" - "Odaberi više slika" - "Potvrdite odabir" "+%d" "Audioporuka nije snimljena. Pokušajte ponovo." "Audioporuku nije moguće reproducirati. Pokušajte ponovo." "Audioporuka nije spremljena. Pokušajte ponovo." - "Dodirnite i zadržite" ", " " " ": " @@ -230,14 +216,7 @@ "Poruka nije uspjela. Dodirnite za ponovni pokušaj." "Razgovor sa sljedećima: %s" "Brisanje predmeta" - "Snimi videozapis" - "Snimi fotografiju" - "Snimi sliku" - "Početak snimanja videozapisa" - "Prebacivanje na fotoaparat na punom zaslonu" "Prebacivanje između prednjeg i stražnjeg fotoaparata" - "Zaustavi snimanje i priloži videozapis" - "Zaustavi snimanje videozapisa" "Fotografije Slanja poruka" %d fotografija spremljena je u album \"%s\" @@ -436,7 +415,6 @@ "Blokirani kontakti" "DEBLOKIRAJ" "Blokirani kontakti" - "Odaberite sliku iz knjižnice dokumenata" "Slanje poruke" "Poruka je poslana" "Mobilni su podaci isključeni. Provjerite postavke." diff --git a/res/values-hu/strings.xml b/res/values-hu/strings.xml index 92035140..7b1668ee 100644 --- a/res/values-hu/strings.xml +++ b/res/values-hu/strings.xml @@ -37,15 +37,7 @@ "Gyakran használt névjegyek" "Az összes névjegy" "Küldés: %s" - "Kép vagy videó rögzítése" - "Képek kiválasztása erről az eszközről" - "Hanganyag rögzítése" "Fotó kiválasztása" - "Kijelölte a médiaelemet." - "Visszavonta a médiaelem kijelölését." - "%d kiválasztva" - "kép %1$tB %1$te %1$tY %1$tl %1$tM %1$tp" - "kép" "Hanganyag rögzítése" "Megosztás" "Éppen most" @@ -138,9 +130,6 @@ "Indítás" "A kamera nem érhető el" - "A kamera nem érhető el" - "A videorögzítés nem érhető el" - "Nem lehetett menteni a médiát" "Nem lehet képet készíteni" "Vissza" "Archiválva" @@ -163,13 +152,10 @@ "Törlés" "Mégse" "Címzett" - "Több kép kiválasztása" - "Kiválasztás megerősítése" "+%d" "Nem sikerült rögzíteni a hangot. Próbálja újra." "A hanganyag nem játszható le. Próbálja újra." "Nem sikerült elmenteni a hangot. Próbálja újra." - "Érintés és tartás" ", " " " ": " @@ -218,14 +204,7 @@ "Sikertelen üzenet. Érintse meg az újrapróbáláshoz." "Beszélgetés a következőkkel: %s" "Tárgy törlése" - "Videó rögzítése" - "Állókép rögzítése" - "Kép készítése" - "Videofelvétel megkezdése" - "Váltás teljes képernyős kameranézetre" "Váltás az elülső és a hátulsó kamera között" - "Rögzítés leállítása és a videó csatolása" - "Videofelvétel leállítása" "Messaging-fotók" %d fotó mentve a következő albumba: %s @@ -410,7 +389,6 @@ "Letiltott ismerősök" "FELOLD" "Letiltott ismerősök" - "Kép választása a dokumentumtárból" "Üzenet küldése" "Üzenet elküldve" "A mobiladat-kapcsolat ki van kapcsolva. Ellenőrizze beállításait." diff --git a/res/values-hy/strings.xml b/res/values-hy/strings.xml index 4dd99b09..1cdc8fc7 100644 --- a/res/values-hy/strings.xml +++ b/res/values-hy/strings.xml @@ -37,15 +37,7 @@ "Հաճախակի օգտագործվող կոնտակտներ" "Բոլոր կոնտակտները" "Ուղարկել %s համարին" - "Լուսանկարել կամ տեսագրել" - "Ընտրեք պատկերներ այս սարքից" - "Ձայնագրել ձայնանյութ" "Ընտրել լուսանկար" - "Մեդիա ֆայլն ընտրված է:" - "Մեդիա ֆայլն ապընտրված է:" - "%d ընտրված" - "պատկեր %1$tB %1$te %1$tY %1$tl %1$tM %1$tp" - "պատկեր" "Ձայնագրել ձայնանյութ" "Տարածել" "Հենց հիմա" @@ -138,9 +130,6 @@ "Սկսել" "Խցիկն անհասանելի է" - "Խցիկն անհասանելի է" - "Տեսանյութը հասանելի չէ" - "Հնարավոր չէ պահել մեդիա ֆայլը" "Չհաջողվեց լուսանկարել" "Հետ" "Արխիվացված" @@ -163,13 +152,10 @@ "Ջնջել" "Չեղարկել" "Ստացող`" - "Ընտրել մի քանի պատկեր" - "Հաստատել ընտրությունը" "+%d" "Հնարավոր չէ ձայնագրել: Կրկին փորձեք:" "Հնարավոր չէ նվագարկել աուդիո ֆայլը: Կրկին փորձեք:" "Հնարավոր չէր պահել աուդիո ֆայլը: Կրկին փորձեք:" - "Հպել և պահել" ", " " " ": " @@ -218,14 +204,7 @@ "Հաղորդագրությունը չի առաքվել: Հպեք՝ նորից փորձելու համար:" "Զրույց %s-ի հետ" "Ջնջել վերնագիրը" - "Տեսագրել" - "Նկարահանել ստատիկ պատկեր" - "Լուսանկարել" - "Սկսել տեսաձայնագրումը" - "Անցնել լիաէկրան ֆոտոխցիկի" "Փոխարկել դիմացի և հետևի տեսախցիկները" - "Կանգնեցնել ձայնագրումը և կցել տեսանյութը" - "Դադարեցնել տեսանկարահանումը" "Հաղորդակցման լուսանկարներ" «%s» ալբոմում պահվել է %d լուսանկար @@ -410,7 +389,6 @@ "Արգելափակված կոնտակտներ" "ԱՐԳԵԼԱԲԱՑԵԼ" "Արգելափակված կոնտակտներ" - "Ընտրեք պատկեր՝ փաստաթղթերի գրադարանից" "Հաղորդագրությունն ուղարկվում է" "Հաղորդագրությունն ուղարկվել է" "Բջջային տվյալների կապն անջատված է: Ստուգեք ձեր կարգավորումները:" diff --git a/res/values-in/strings.xml b/res/values-in/strings.xml index 3b65bb25..5216dcee 100644 --- a/res/values-in/strings.xml +++ b/res/values-in/strings.xml @@ -37,15 +37,7 @@ "Sering" "Semua kontak" "Kirim ke %s" - "Jepret gambar atau rekam video" - "Pilih gambar dari perangkat ini" - "Rekam audio" "Pilih foto" - "Media ini dipilih." - "Media ini batal dipilih." - "%d dipilih" - "gambar %1$tB %1$te %1$tY %1$tl %1$tM %1$tp" - "gambar" "Rekam audio" "Bagikan" "Baru saja" @@ -138,9 +130,6 @@ "Mulai" "Tidak ada kamera" - "Tidak ada kamera" - "Tidak ada rekaman video" - "Tidak dapat menyimpan media" "Tidak dapat memfoto" "Kembali" "Diarsipkan" @@ -163,13 +152,10 @@ "Hapus" "Batal" "Kepada" - "Pilih beberapa gambar" - "Konfirmasi pilihan" "+%d" "Tidak dapat merekam audio. Coba lagi." "Tidak dapat memutar audio. Coba lagi." "Tidak dapat menyimpan audio. Coba lagi." - "Sentuh & tahan" ", " " " ": " @@ -218,14 +204,7 @@ "Pesan gagal. Sentuh untuk mencoba lagi." "Percakapan dengan %s" "Hapus subjek" - "Ambil video" - "Ambil gambar diam" - "Ambil gambar" - "Mulai merekam video" - "Beralih ke kamera layar penuh" "Beralih antara kamera depan dan belakang" - "Hentikan perekaman dan lampirkan video" - "Berhenti merekam video" "Foto Perpesanan" %d foto disimpan ke album \"%s\" @@ -410,7 +389,6 @@ "Kontak yang diblokir" "BEBASKAN" "Kontak yang diblokir" - "Pilih gambar dari perpustakaan dokumen" "Mengirim pesan" "Pesan terkirim" "Data seluler dinonaktifkan. Periksa setelan." diff --git a/res/values-is/strings.xml b/res/values-is/strings.xml index f02a11c0..f7e142f6 100644 --- a/res/values-is/strings.xml +++ b/res/values-is/strings.xml @@ -37,15 +37,7 @@ "Algengir" "Allir tengiliðir" "Senda í %s" - "Taka myndir eða myndskeið" - "Velja myndir úr þessu tæki" - "Taka upp hljóð" "Velja mynd" - "Efnið er valið." - "Efnið er ekki valið." - "%d valin" - "mynd %1$tB %1$te %1$tY %1$tl %1$tM %1$tp" - "mynd" "Taka upp hljóð" "Deila" "Rétt í þessu" @@ -138,9 +130,6 @@ "Byrja" "Myndavélin er ekki tiltæk" - "Myndavélin er ekki tiltæk" - "Upptaka myndskeiða er ekki tiltæk" - "Ekki er hægt að vista efni" "Ekki er hægt að taka mynd" "Til baka" "Geymsla" @@ -163,13 +152,10 @@ "Eyða" "Hætta við" "Til" - "Velja margar myndir" - "Staðfesta val" "+%d" "Ekki er hægt að taka upp hljóð. Reyndu aftur." "Ekki er hægt að spila hljóð. Reyndu aftur." "Ekki var hægt að vista hljóð. Reyndu aftur." - "Haltu inni" ", " " " ": " @@ -218,14 +204,7 @@ "Misheppnuð skilaboð. Snertu til að reyna aftur." "Samtal við %s" "Eyða efni" - "Taka upp myndskeið" - "Taka kyrrmynd" - "Taka mynd" - "Byrja að taka upp myndskeið" - "Skipta yfir í myndavél á öllum skjánum" "Skipt á milli fremri og aftari myndavélar" - "Stöðva upptöku og hengja myndskeið við" - "Stöðva upptöku myndskeiðs" "Skilaboðamyndir" %d mynd vistuð í „%s“ möppu @@ -410,7 +389,6 @@ "Útilokaðir tengiliðir" "TAKA AF BANNLISTA" "Útilokaðir tengiliðir" - "Velja mynd úr skjalasafni" "Sendir skilaboð" "Skilaboð send" "Slökkt er á farsímagögnum. Athugaðu stillingarnar." diff --git a/res/values-it/strings.xml b/res/values-it/strings.xml index f0417936..b13743af 100644 --- a/res/values-it/strings.xml +++ b/res/values-it/strings.xml @@ -37,15 +37,7 @@ "Frequenti" "Tutti i contatti" "Invia a %s" - "Scatta foto o registra video" - "Scegli immagini su questo dispositivo" - "Registra audio" "Scegli foto" - "Il contenuto multimediale è selezionato." - "Il contenuto multimediale è deselezionato." - "%d selezionati" - "immagine: %1$tB %1$te %1$tY %1$tl %1$tM %1$tp" - "immagine" "Registrazione audio" "Condividi" "Adesso" @@ -138,9 +130,6 @@ "Avvia" "Fotocamera non disponibile" - "Fotocamera non disponibile" - "Acquisizione video non disponibile" - "Impossibile salvare i contenuti multimediali" "Impossibile scattare una foto" "Indietro" "Archiviate" @@ -163,13 +152,10 @@ "Elimina" "Annulla" "A" - "Seleziona diverse immagini" - "Conferma selezione" "e altri %d" "Impossibile registrare l\'audio. Riprova." "Impossibile riprodurre l\'audio. Riprova." "Impossibile salvare l\'audio. Riprova." - "Tocca e tieni premuto" ", " " " ": " @@ -218,14 +204,7 @@ "Messaggio non recapitato. Tocca per riprovare." "Conversazione con %s" "Elimina oggetto" - "Registra video" - "Scatta una foto in posa" - "Scatta una foto" - "Inizia a registrare il video" - "Passa alla fotocamera a schermo intero" "Passa dalla fotocamera frontale a quella posteriore e viceversa" - "Interrompi registrazione e aggiungi video" - "Interrompi registrazione video" "Foto in Messaggi" %d foto salvate nell\'album \"%s\" @@ -410,7 +389,6 @@ "Contatti bloccati" "SBLOCCA" "Contatti bloccati" - "Scegli immagine da raccolta documenti" "Invio messaggio" "Messaggio inviato" "Dati mobili non attivi. Controlla le impostazioni." diff --git a/res/values-iw/strings.xml b/res/values-iw/strings.xml index 3f80e3c4..93845a2a 100644 --- a/res/values-iw/strings.xml +++ b/res/values-iw/strings.xml @@ -37,15 +37,7 @@ "בתדירות גבוהה" "כל אנשי הקשר" "שלח אל %s" - "צלם תמונות או סרטון" - "בחר תמונות ממכשיר זה" - "הקלט אודיו" "בחר תמונה" - "בחרת במדיה." - "ביטלת את הבחירה במדיה." - "%d נבחרו" - "תמונה %1$tB %1$te %1$tY %1$tl %1$tM %1$tp" - "תמונה" "הקלט אודיו" "שיתוף" "ממש עכשיו" @@ -156,9 +148,6 @@ "התחל" "המצלמה לא זמינה" - "המצלמה לא זמינה" - "לכידת וידאו אינה זמינה" - "לא ניתן לשמור מדיה" "לא ניתן לצלם את התמונה" "הקודם" "הועברו לארכיון" @@ -183,13 +172,10 @@ "מחק" "בטל" "אל" - "בחר תמונות מרובות" - "אשר את הבחירה" "+%d" "לא ניתן להקליט אודיו. נסה שוב." "לא ניתן להפעיל אודיו. נסה שוב." "לא ניתן לשמור אודיו. נסה שוב." - "גע והחזק" ", " " " ": " @@ -242,14 +228,7 @@ "ההודעה נכשלה. גע כדי לנסות שוב." "שיחה עם %s" "מחק נושא" - "צלם סרטון" - "צלם תמונת סטילס" - "צלם תמונה" - "התחל הקלטת סרטון" - "עבור למצלמה במסך מלא" "החלף בין המצלמה הקדמית לאחורית" - "הפסק להקליט וצרף סרטון" - "עצור הקלטת וידאו" "תמונות ב\'העברת הודעות\'" %d תמונות נשמרו באלבום \"%s\" @@ -462,7 +441,6 @@ "אנשי קשר חסומים" "אפשר" "אנשי קשר חסומים" - "בחר תמונה מספריית המסמכים" "שולח את ההודעה" "ההודעה נשלחה" "נתונים סלולריים כבויים. בדוק את ההגדרות." diff --git a/res/values-ja/strings.xml b/res/values-ja/strings.xml index a3a513ec..48077884 100644 --- a/res/values-ja/strings.xml +++ b/res/values-ja/strings.xml @@ -37,15 +37,7 @@ "よく使う連絡先" "すべての連絡先" "%sに送信" - "写真や動画を撮影" - "この端末から画像を選択" - "音声の録音" "写真を選択" - "メディアを選択しました。" - "メディアの選択を解除しました。" - "%d件選択済み" - "画像%1$tB %1$te %1$tY %1$tl %1$tM %1$tp" - "画像" "音声の録音" "共有" "たった今" @@ -138,9 +130,6 @@ "開始" "カメラは利用できません" - "カメラは利用できません" - "動画撮影は利用できません" - "メディアを保存できません" "写真を撮ることはできません" "戻る" "アーカイブ済み" @@ -163,13 +152,10 @@ "削除" "キャンセル" "宛先" - "複数の画像を選択" - "選択の確認" "+%d" "音声を録音できません。もう一度お試しください。" "音声を再生できません。もう一度お試しください。" "音声を保存できませんでした。もう一度お試しください。" - "長押し" "、 " " " ": " @@ -218,14 +204,7 @@ "失敗したメッセージ。再試行するにはタップしてください。" "%sとのスレッド" "件名を削除" - "動画を撮影" - "静止画を撮影" - "画像を撮影" - "動画の録画を開始" - "全画面カメラに切り替える" "前面カメラと背面カメラを切り替える" - "録画を停止し、動画を添付する" - "動画の録画を停止" "SMSの写真" %d枚の写真を「%s」アルバムに保存しました @@ -410,7 +389,6 @@ "ブロック中の連絡先" "ブロックを解除" "ブロック中の連絡先" - "ドキュメントライブラリから画像を選択" "メールを送信しています" "メッセージを送信しました" "モバイルデータがOFFになっています。設定を確認してください。" diff --git a/res/values-ka/strings.xml b/res/values-ka/strings.xml index 8a2cd5ec..b59c96ff 100644 --- a/res/values-ka/strings.xml +++ b/res/values-ka/strings.xml @@ -37,15 +37,7 @@ "ხშირად გამოყენებული კონტაქტები" "ყველა კონტაქტი" "%s-ზე გაგზავნა" - "სურათების ან ვიდეოს გადაღება" - "ამ მოწყობილობიდან სურათების არჩევა" - "აუდიოს ჩაწერა" "ფოტოს არჩევა" - "მედია შერჩეულია." - "მედია არ არის შერჩეული." - "%d შერჩეულია" - "სურათის %1$tB %1$te %1$tY %1$tl %1$tM %1$tp" - "სურათი" "აუდიოს ჩაწერა" "გაზიარება" "ახლახანს" @@ -138,9 +130,6 @@ "დაწყება" "კამერა მიუწვდომელია" - "კამერა მიუწვდომელია" - "ვიდეო ჩაწერა მიუწვდომელია" - "მედიას შენახვა ვერ ვერხდება" "ვერ ხორციელდება სურათის გადაღება" "უკან" "დაარქივებული" @@ -163,13 +152,10 @@ "წაშლა" "გაუქმება" "მიმღები:" - "მრავალი სურათის არჩევა" - "არჩევანის დადასტურება" "+%d" "ხმის ჩაწერა ვერ ხერხდენა. სცადეთ ისევ." "ხმის დაკვრა ვერ ხერხდება. სცადეთ ისევ." "ხმის ჩაწერა ვერ მოხერხდა. სცადეთ ისევ." - "შეეხეთ & დააყოვნეთ" ", " " " ": " @@ -218,14 +204,7 @@ "შეტყობინების შეცდომა. შეეხეთ, რათა ხელახლა სცადოთ." "საუბარი %s -სთან" "თემის წაშლა" - "ვიდეოს გადაღება" - "ფოტოსურათის აღბეჭდვა" - "სურათის გადაღება" - "ვიდეოს ჩაწერის დაწყება" - "სრულეკრანიან კამერაზე გადართვა" "წინა და უკანა კამერებს შორის გადართვა" - "ჩაწერის შეჩერება და ვიდეოს მიბმა" - "ვიდეოს ჩაწერის შეჩერება" "შეტყობინებების ფოტოები" %d ფოტო შენახულია ალბომში „%s @@ -410,7 +389,6 @@ "დაბლოკილი კონტაქტები" "განბლოკვა" "დაბლოკილი კონტაქტები" - "აირჩიეთ სურათი დოკუმენტების ბიბლიოთეკიდან" "შეტყობინება იგზავნება" "შეტყობინება გაიგზავნა" "მობილური ინტერნეტი გამორთულია. გადაამოწმეთ პარამეტრები." diff --git a/res/values-kk/strings.xml b/res/values-kk/strings.xml index 386e8ef7..62a659f5 100644 --- a/res/values-kk/strings.xml +++ b/res/values-kk/strings.xml @@ -37,15 +37,7 @@ "Жиі қолданылатын" "Барлық контактілер" "%s орнына жіберу" - "Суреттер немесе бейне түсіру" - "Осы құрылғыдан кескіндерді таңдау" - "Аудио жазу" "Фотосуретті таңдау" - "Медиа таңдалды." - "Медиадан таңдау алынды." - "%d таңдалды" - "кескін %1$tB %1$te %1$tY %1$tl %1$tM %1$tp" - "кескін" "Аудио жазу" "Бөлісу" "Қазір ғана" @@ -138,9 +130,6 @@ "Бастау" "Камера қол жетімді емес" - "Камера қол жетімді емес" - "Бейне түсіру қол жетімді емес" - "Медианы сақтау мүмкін емес" "Сурет түсіру мүмкін емес" "Артқа" "Мұрағатталды" @@ -163,13 +152,10 @@ "Жою" "Бас тарту" "Кімге" - "Бірнеше кескінді таңдау" - "Таңдауды растау" "+%d" "Аудионы жазу мүмкін емес. Әрекетті қайталаңыз." "Аудионы ойнату мүмкін емес. Әрекетті қайталаңыз." "Аудионы сақтау мүмкін болмады. Әрекетті қайталаңыз." - "Түрту және ұстап тұру" ", " " " ": " @@ -218,14 +204,7 @@ "Сәтсіз хабар. Қайталау үшін түртіңіз." "%s адаммен сөйлесу" "Тақырыпты жою" - "Бейне түсіру" - "Фотосурет түсіру" - "Сурет түсіру" - "Бейне жазуды бастау" - "Толық экранды камераға ауысу" "Алдыңғы және артқы камера арасында ауысу" - "Жазуды тоқтату және бейне тіркеу" - "Бейне жазуды тоқтату" "Messaging фотосуреттері" %d фотосурет «%s» альбомына сақталды @@ -410,7 +389,6 @@ "Бөгелген контактілер" "БӨГЕУДЕН ШЫҒАРУ" "Бөгелген контактілер" - "Құжаттар кітапханасынан кескінді таңдау" "Хабар жіберілуде" "Хабар жіберілді" "Ұялы деректер өшірілген. Параметрлерді тексеріңіз." diff --git a/res/values-km/strings.xml b/res/values-km/strings.xml index fba58e2e..ecd69874 100644 --- a/res/values-km/strings.xml +++ b/res/values-km/strings.xml @@ -37,15 +37,7 @@ "ញឹកញាប់" "ទំនាក់ទំនងទាំងអស់" "ផ្ញើ​ទៅ %s" - "ថត​រូបភាព ឬ​វីដេអូ" - "ជ្រើស​រូបភាព​ពី​ឧបករណ៍​នេះ" - "ថត​សំឡេង" "ជ្រើស​រូបថត" - "បានជ្រើសឯកសារកំសាន្ត។" - "បានលុបការជ្រើសឯកសារកំសាន្ត។" - "បាន​ជ្រើស %d" - "រូបភាព %1$tB %1$te %1$tY %1$tl %1$tM %1$tp" - "រូបភាព" "ថត​សំឡេង" "ចែករំលែក​" "ថ្មីៗ​នេះ" @@ -138,9 +130,6 @@ "ចាប់ផ្ដើម" "មិន​អាច​ប្រើ​ម៉ាស៊ីន​ថត​បាន" - "មិន​អាច​ប្រើ​ម៉ាស៊ីន​ថត​បាន" - "មិន​អាច​ថត​វីដេអូ​បាន" - "មិន​អាច​រក្សាទុក​​មេឌៀ" "មិនអាចថតរូបភាព" "ថយក្រោយ" "ទុក​ក្នុង​ប័ណ្ណសារ" @@ -163,13 +152,10 @@ "លុប" "បោះបង់" "ជូន​ចំពោះ" - "ជ្រើស​រូបភាព​ច្រើន" - "បញ្ជាក់​​ការ​ជ្រើស" "+%d" "មិន​អាច​ថត​សំឡេង។ ព្យាយាម​ម្ដង​ទៀត។" "មិន​អាច​ចាក់​សំឡេង។ ព្យាយាម​ម្ដងទៀត។" "មិន​អាច​រក្សាទុក​សំឡេង។ ព្យាយាម​ម្ដងទៀត។" - "ប៉ះ & សង្កត់" ", " " " ": " @@ -218,14 +204,7 @@ "សារបរាជ័យ។ ប៉ះដើម្បីព្យាយាមម្តងទៀត។" "ការសន្ទនាជាមួយ %s" "លុប​ប្រធានបទ" - "ថត​វីដេអូ" - "ថត​រូបភាព​ថេរ" - "ថតរូប" - "ចាប់ផ្ដើម​ថត​វីដេអូ" - "ប្ដូរ​ទៅ​ម៉ាស៊ីន​ថត​ពេញ​អេក្រង់" "ប្ដូរ​រវាង​ម៉ាស៊ីន​ថត​មុន និង​ក្រោយ" - "បញ្ឈប់​ការ​ថត និង​ភ្ជាប់​វីដេអូ" - "បញ្ឈប់​ការ​ថត​វីដេអូ" "រូបភាពសារ" បានរក្សាទុករូបថត %d ទៅក្នុងអាល់ប៊ុម \"%s\" @@ -410,7 +389,6 @@ "បាន​ទប់ស្កាត់​ទំនាក់ទំនង" "មិន​ទប់ស្កាត់" "បាន​ទប់ស្កាត់​ទំនាក់ទំនង​" - "ជ្រើស​រូបភាព​ពី​បណ្ណាល័យ​ឯកសារ" "កំពុងផ្ញើសារ" "បានផ្ញើសារ" "ទិន្នន័យ​ចល័ត​ត្រូវ​បាន​បិទ។ ពិនិត្យ​ការ​កំណត់​របស់​អ្នក។" diff --git a/res/values-kn/strings.xml b/res/values-kn/strings.xml index b4840093..831b5c2d 100644 --- a/res/values-kn/strings.xml +++ b/res/values-kn/strings.xml @@ -37,15 +37,7 @@ "ಪುನರಾವರ್ತನೆಗಳು" "ಎಲ್ಲ ಸಂಪರ್ಕಗಳು" "%s ಗೆ ಕಳುಹಿಸಿ" - "ಚಿತ್ರಗಳು ಅಥವಾ ವೀಡಿಯೊವನ್ನು ಸೆರೆಹಿಡಿಯಿರಿ" - "ಈ ಸಾಧನದಿಂದ ಚಿತ್ರಗಳನ್ನು ಆಯ್ಕೆಮಾಡಿ" - "ಆಡಿಯೋ ರೆಕಾರ್ಡ್‌ ಮಾಡಿ" "ಫೋಟೋ ಆಯ್ಕೆಮಾಡಿ" - "ಮಾಧ್ಯಮ ಆಯ್ಕೆಮಾಡಲಾಗಿದೆ." - "ಮಾಧ್ಯಮ ಆಯ್ಕೆ ರದ್ದುಗೊಳಿಸಲಾಗಿದೆ." - "%d ಆಯ್ಕೆಮಾಡಲಾಗಿದೆ" - "ಚಿತ್ರ %1$tB %1$te %1$tY %1$tl %1$tM %1$tp" - "ಚಿತ್ರ" "ಆಡಿಯೋ ರೆಕಾರ್ಡ್‌ ಮಾಡಿ" "ಹಂಚಿಕೊಳ್ಳಿ" "ಈಗ ತಾನೇ" @@ -138,9 +130,6 @@ "ಪ್ರಾರಂಭಿಸು" "ಕ್ಯಾಮರಾ ಲಭ್ಯವಿಲ್ಲ" - "ಕ್ಯಾಮರಾ ಲಭ್ಯವಿಲ್ಲ" - "ವಿಡಿಯೋ ಸೆರೆಹಿಡಿಯುವಿಕೆ ಲಭ್ಯವಿಲ್ಲ" - "ಮಾಧ್ಯಮ ಉಳಿಸಲಾಗಲಿಲ್ಲ" "ಫೋಟೋ ತೆಗೆದುಕೊಳ್ಳಲು ಸಾಧ್ಯವಿಲ್ಲ" "ಹಿಂದೆ" "ಆರ್ಕೈವ್ ಮಾಡಲಾಗಿದೆ" @@ -163,13 +152,10 @@ "ಅಳಿಸಿ" "ರದ್ದುಮಾಡು" "ಇವರಿಗೆ" - "ಬಹು ಚಿತ್ರಗಳನ್ನು ಆಯ್ಕೆಮಾಡಿ" - "ಆಯ್ಕೆ ಖಚಿತಪಡಿಸಿ" "+%d" "ಆಡಿಯೋ ರೆಕಾರ್ಡ್ ಮಾಡಲು ಸಾಧ್ಯವಿಲ್ಲ. ಮತ್ತೆ ಪ್ರಯತ್ನಿಸಿ." "ಆಡಿಯೋವನ್ನು ಪ್ಲೇ ಮಾಡಲು ಸಾಧ್ಯವಿಲ್ಲ. ಮತ್ತೆ ಪ್ರಯತ್ನಿಸಿ." "ಆಡಿಯೋವನ್ನು ಉಳಿಸಲಾಗಲಿಲ್ಲ. ಮತ್ತೆ ಪ್ರಯತ್ನಿಸಿ." - "ಸ್ಪರ್ಶಿಸಿ & ಹೋಲ್ಡ್‌ ಮಾಡಿ" ", " " " ": " @@ -218,14 +204,7 @@ "ಸಂದೇಶ ವಿಫಲವಾಗಿದೆ. ಮರುಪ್ರಯತ್ನಿಸಲು ಸ್ಪರ್ಶಿಸಿ." "%s ಅವರೊಂದಿಗೆ ಸಂಭಾಷಣೆ" "ವಿಷಯವನ್ನು ಅಳಿಸಿ" - "ವೀಡಿಯೊ ಸೆರೆಹಿಡಿಯಿರಿ" - "ಸ್ಥಿರ ಚಿತ್ರವನ್ನು ಸೆರೆಹಿಡಿಯಿರಿ" - "ಫೋಟೋ ತೆಗೆಯಿರಿ" - "ವೀಡಿಯೊ ರೆಕಾರ್ಡಿಂಗ್ ಪ್ರಾರಂಭಿಸಿ" - "ಪೂರ್ಣ ಪರದೆ ಕ್ಯಾಮರಾಗೆ ಬದಲಿಸಿ" "ಮುಂದಿನ ಮತ್ತು ಹಿಂದಿನ ಕ್ಯಾಮರಾ ನಡುವೆ ಬದಲಾಯಿಸಿ" - "ರೆಕಾರ್ಡಿಂಗ್ ನಿಲ್ಲಿಸಿ ಮತ್ತು ವೀಡಿಯೊ ಲಗತ್ತಿಸಿ" - "ವೀಡಿಯೊ ರೆಕಾರ್ಡಿಂಗ್ ನಿಲ್ಲಿಸಿ" "ಫೋಟೋಗಳ ಸಂದೇಶ ಕಳುಹಿಸುವಿಕೆ" \"%s\" ಆಲ್ಬಮ್‌ಗೆ %d ಫೋಟೋಗಳನ್ನು ಉಳಿಸಲಾಗಿದೆ @@ -410,7 +389,6 @@ "ನಿರ್ಬಂಧಿಸಲಾದ ಸಂಪರ್ಕಗಳು" "ಅನಿರ್ಬಂಧಿಸು" "ನಿರ್ಬಂಧಿಸಲಾದ ಸಂಪರ್ಕಗಳು" - "ಡಾಕ್ಯುಮೆಂಟ್ ಲೈಬ್ರರಿಯಿಂದ ಚಿತ್ರವನ್ನು ಆಯ್ಕೆಮಾಡಿ" "ಸಂದೇಶ ಕಳುಹಿಸಲಾಗುತ್ತಿದೆ" "ಸಂದೇಶ ಕಳುಹಿಸಲಾಗಿದೆ" "ಸೆಲ್ಯುಲಾರ್ ಡೇಟಾ ಆಫ್ ಮಾಡಲಾಗಿದೆ. ನಿಮ್ಮ ಸೆಟ್ಟಿಂಗ್‌ಗಳನ್ನು ಪರೀಕ್ಷಿಸಿ." diff --git a/res/values-ko/strings.xml b/res/values-ko/strings.xml index 1faec2ab..e69957cf 100644 --- a/res/values-ko/strings.xml +++ b/res/values-ko/strings.xml @@ -37,15 +37,7 @@ "자주 연락하는 사람" "모든 연락처" "받는사람: %s" - "사진 또는 동영상 캡처" - "이 기기에서 이미지 선택" - "오디오 녹음" "사진 선택" - "미디어가 선택되었습니다." - "미디어가 선택 취소되었습니다." - "%d개 선택됨" - "이미지 - %1$tB %1$te %1$tY %1$tl %1$tM %1$tp" - "이미지" "오디오 녹음" "공유" "방금 전" @@ -138,9 +130,6 @@ "시작" "카메라를 사용할 수 없습니다." - "카메라를 사용할 수 없습니다." - "동영상을 캡처할 수 없습니다." - "미디어를 저장할 수 없습니다." "사진을 찍을 수 없음" "뒤로" "보관처리됨" @@ -163,13 +152,10 @@ "삭제" "취소" "받는사람" - "여러 이미지 선택" - "선택항목 확인" "+%d" "오디오를 녹음할 수 없습니다. 다시 시도해 주세요." "오디오를 재생할 수 없습니다. 다시 시도해 주세요." "오디오를 저장할 수 없습니다. 다시 시도해 주세요." - "길게 터치" ", " " " ": " @@ -218,14 +204,7 @@ "메시지를 전달하지 못했습니다. 다시 시도하려면 터치하세요." "%s님과 대화" "제목 삭제" - "동영상 촬영" - "정지 이미지 캡처" - "사진 촬영" - "동영상 녹화 시작" - "전체화면 카메라로 전환" "전면 및 후면 카메라 전환" - "녹화 중지 및 비디오 첨부" - "동영상 녹화 중지" "메시지 사진" 사진 %d장이 \"%s\" 앨범에 저장됨 @@ -410,7 +389,6 @@ "차단된 연락처" "차단 해제" "차단된 연락처" - "문서 라이브러리에서 이미지 선택" "메시지를 전송하는 중입니다." "메시지가 전송되었습니다." "이동통신 데이터 사용이 중지되어 있습니다. 설정을 확인하세요." diff --git a/res/values-ky/strings.xml b/res/values-ky/strings.xml index 96b6d86c..6b8a0744 100644 --- a/res/values-ky/strings.xml +++ b/res/values-ky/strings.xml @@ -37,15 +37,7 @@ "Көп байланышкандар" "Бардык байланыштар" "Төмөнкүгө жөнөтүү %s" - "Сүрөттөрдү же видео тартып алуу" - "Бул түзмөктөн сүрөттөрдү тандаңыз" - "Аудио жаздыруу" "Сүрөт тандаңыз" - "Медиа файл тандалды." - "Медиа файл тандоодон чыгарылды." - "%d тандалды" - "сүрөт %1$tB %1$te %1$tY %1$tl %1$tM %1$tp" - "сүрөт" "Аудио жаздыруу" "Бөлүшүү" "Жаңы эле" @@ -138,9 +130,6 @@ "Баштоо" "Камера жеткиликтүү эмес" - "Камера жеткиликтүү эмес" - "Видеону тартып алуу жеткиликтүү эмес" - "Медиа сакталбай жатат" "Сүрөткө тарта албайт" "Артка" "Архивделди" @@ -163,13 +152,10 @@ "Жок кылуу" "Жокко чыгаруу" "Кимге" - "Бир нече сүрөт тандаңыз" - "Тандалгандарды ырастаңыз" "+%d" "Аудио жазылбай жатат. Дагы аракет кылыңыз." "Аудио ойнотулбай жатат. Дагы аракет кылыңыз." "Аудио сакталган жок. Дагы аракет кылыңыз." - "Коё бербей amp; тийип туруу" ", " " " ": " @@ -218,14 +204,7 @@ "Билдирүү жөнөтүлбөй койду. Кайра аракет кылуу үчүн тийип коюңуз." "%s менен сүйлөшүү" "Темасын жок кылуу" - "Видео жаздыруу" - "Кыймылдабаган сүрөттү тартып алуу" - "Сүрөт тартуу" - "Видео тартып баштоо" - "Камераны толук экранга которуу" "Алдыңкы жана арткы камераны которуштуруу" - "Тартылып жаткан видеону токтотуп, тиркеңиз" - "Видео жаздырууну токтотуу" "SMS/MMS сүрөттөрү" %d сүрөт \"%s\" альбомуна сакталды @@ -410,7 +389,6 @@ "Бөгөттөлгөн байланыштар" "БӨГӨТТӨН ЧЫГАРУУ" "Бөгөттөлгөн байланыштар" - "Документтер китепканасынан сүрөт тандаңыз" "Билдирүү жөнөтүлүүдө…" "Билдирүү жөнөтүлдү" "Уюлдук дайындар өчүрүлгөн. Жөндөөлөрүңүздү текшериңиз." diff --git a/res/values-ldrtl/styles.xml b/res/values-ldrtl/styles.xml index 67dbc3dd..5820eeef 100644 --- a/res/values-ldrtl/styles.xml +++ b/res/values-ldrtl/styles.xml @@ -86,10 +86,6 @@ @dimen/fab_left_right_margin - - - - - - - - diff --git a/src/com/android/messaging/ui/UIIntents.java b/src/com/android/messaging/ui/UIIntents.java index 9d7e8bcd..6a704c69 100644 --- a/src/com/android/messaging/ui/UIIntents.java +++ b/src/com/android/messaging/ui/UIIntents.java @@ -25,8 +25,6 @@ import android.net.Uri; import android.os.Bundle; -import androidx.fragment.app.Fragment; - import com.android.messaging.Factory; import com.android.messaging.datamodel.data.MessageData; import com.android.messaging.util.ConversationIdSet; @@ -45,12 +43,6 @@ public static UIIntents get() { // Sending draft data (from share intent / message forwarding) to the ConversationActivity. public static final String UI_INTENT_EXTRA_DRAFT_DATA = "draft_data"; - // The request code for picking a media from the Document picker. - public static final int REQUEST_PICK_MEDIA_FROM_DOCUMENT_PICKER = 1400; - - // The request code for picking a contact card from existing Contacts apps. - public static final int REQUEST_PICK_CONTACT_CARD = 1500; - // Indicates what type of notification this applies to (See BugleNotifications: // UPDATE_NONE, UPDATE_MESSAGES, UPDATE_ERRORS, UPDATE_ALL) public static final String UI_INTENT_EXTRA_NOTIFICATIONS_UPDATE = "notifications_update"; @@ -168,20 +160,6 @@ public abstract void launchCreateNewConversationActivity(final Context context, */ public abstract void launchAddContactActivity(final Context context, final String destination); - /** - * Launch an activity to show the document picker to pick an image/video/audio. - * - * @param fragment the requesting fragment - */ - public abstract void launchDocumentImagePicker(final Fragment fragment); - - /** - * Launch an activity to show the contacts list to pick one. - * - * @param fragment the requesting fragment - */ - public abstract void launchContactCardPicker(final Fragment fragment); - /** * Launch an activity to show people & options for a given conversation. */ diff --git a/src/com/android/messaging/ui/UIIntentsImpl.java b/src/com/android/messaging/ui/UIIntentsImpl.java index 9c5d18f0..e980da69 100644 --- a/src/com/android/messaging/ui/UIIntentsImpl.java +++ b/src/com/android/messaging/ui/UIIntentsImpl.java @@ -67,7 +67,6 @@ import androidx.annotation.Nullable; import androidx.core.app.TaskStackBuilder; -import androidx.fragment.app.Fragment; import androidx.localbroadcastmanager.content.LocalBroadcastManager; /** @@ -233,29 +232,6 @@ public void launchBlockedParticipantsActivity(final Context context) { context.startActivity(intent); } - @Override - public void launchDocumentImagePicker(final Fragment fragment) { - final Intent intent = new Intent(Intent.ACTION_GET_CONTENT); - intent.putExtra(Intent.EXTRA_MIME_TYPES, MessagePartData.ACCEPTABLE_GALLERY_MEDIA_TYPES); - intent.addCategory(Intent.CATEGORY_OPENABLE); - intent.setType(ContentType.ANY_TYPE); - - fragment.startActivityForResult(intent, REQUEST_PICK_MEDIA_FROM_DOCUMENT_PICKER); - } - - @Override - public void launchContactCardPicker(final Fragment fragment) { - final Intent intent = new Intent(Intent.ACTION_PICK); - intent.setType(Contacts.CONTENT_TYPE); - - try { - fragment.startActivityForResult(intent, REQUEST_PICK_CONTACT_CARD); - } catch (final ActivityNotFoundException ex) { - LogUtil.w(LogUtil.BUGLE_TAG, "Couldn't find activity:", ex); - UiUtils.showToastAtBottom(R.string.activity_not_found_message); - } - } - @Override public void launchPeopleAndOptionsActivity(final Activity activity, final String conversationId) { diff --git a/src/com/android/messaging/ui/mediapicker/AudioLevelSource.java b/src/com/android/messaging/ui/mediapicker/AudioLevelSource.java deleted file mode 100644 index a211058b..00000000 --- a/src/com/android/messaging/ui/mediapicker/AudioLevelSource.java +++ /dev/null @@ -1,73 +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.mediapicker; - -import com.google.common.base.Preconditions; - -import javax.annotation.concurrent.ThreadSafe; - -/** - * Keeps track of the speech level as last observed by the recognition - * engine as microphone data flows through it. Can be polled by the UI to - * animate its views. - */ -@ThreadSafe -public class AudioLevelSource { - private volatile int mSpeechLevel; - private volatile Listener mListener; - - public static final int LEVEL_UNKNOWN = -1; - - public interface Listener { - void onSpeechLevel(int speechLevel); - } - - public void setSpeechLevel(int speechLevel) { - Preconditions.checkArgument(speechLevel >= 0 && speechLevel <= 100 || - speechLevel == LEVEL_UNKNOWN); - mSpeechLevel = speechLevel; - maybeNotify(); - } - - public int getSpeechLevel() { - return mSpeechLevel; - } - - public void reset() { - setSpeechLevel(LEVEL_UNKNOWN); - } - - public boolean isValid() { - return mSpeechLevel > 0; - } - - private void maybeNotify() { - final Listener l = mListener; - if (l != null) { - l.onSpeechLevel(mSpeechLevel); - } - } - - public synchronized void setListener(Listener listener) { - mListener = listener; - } - - public synchronized void clearListener(Listener listener) { - if (mListener == listener) { - mListener = null; - } - } -} diff --git a/src/com/android/messaging/ui/mediapicker/AudioMediaChooser.java b/src/com/android/messaging/ui/mediapicker/AudioMediaChooser.java deleted file mode 100644 index 5d79293c..00000000 --- a/src/com/android/messaging/ui/mediapicker/AudioMediaChooser.java +++ /dev/null @@ -1,130 +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.mediapicker; - -import android.Manifest; -import android.content.pm.PackageManager; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; - -import com.android.messaging.R; -import com.android.messaging.datamodel.data.MessagePartData; -import com.android.messaging.util.OsUtil; - -/** - * Chooser which allows the user to record audio - */ -class AudioMediaChooser extends MediaChooser implements - AudioRecordView.HostInterface { - private View mEnabledView; - private View mMissingPermissionView; - - AudioMediaChooser(final MediaPicker mediaPicker) { - super(mediaPicker); - } - - @Override - public int getSupportedMediaTypes() { - return MediaPicker.MEDIA_TYPE_AUDIO; - } - - @Override - public int getIconResource() { - return R.drawable.ic_audio_light; - } - - @Override - public int getIconDescriptionResource() { - return R.string.mediapicker_audioChooserDescription; - } - - @Override - public void onAudioRecorded(final MessagePartData item) { - mMediaPicker.dispatchItemsSelected(item, true); - } - - @Override - public void setThemeColor(final int color) { - if (mView != null) { - ((AudioRecordView) mView).setThemeColor(color); - } - } - - @Override - protected View createView(final ViewGroup container) { - final LayoutInflater inflater = getLayoutInflater(); - final AudioRecordView view = (AudioRecordView) inflater.inflate( - R.layout.mediapicker_audio_chooser, - container /* root */, - false /* attachToRoot */); - view.setHostInterface(this); - view.setThemeColor(mMediaPicker.getConversationThemeColor()); - mEnabledView = view.findViewById(R.id.mediapicker_enabled); - mMissingPermissionView = view.findViewById(R.id.missing_permission_view); - return view; - } - - @Override - int getActionBarTitleResId() { - return R.string.mediapicker_audio_title; - } - - @Override - public boolean isHandlingTouch() { - // Whenever the user is in the process of recording audio, we want to allow the user - // to move the finger within the panel without interpreting that as dragging the media - // picker panel. - return ((AudioRecordView) mView).shouldHandleTouch(); - } - - @Override - public void stopTouchHandling() { - ((AudioRecordView) mView).stopTouchHandling(); - } - - @Override - public void onPause() { - super.onPause(); - if (mView != null) { - ((AudioRecordView) mView).onPause(); - } - } - - @Override - protected void setSelected(final boolean selected) { - super.setSelected(selected); - if (selected && !OsUtil.hasRecordAudioPermission()) { - requestRecordAudioPermission(); - } - } - - private void requestRecordAudioPermission() { - mMediaPicker.requestPermissions(new String[] { Manifest.permission.RECORD_AUDIO }, - MediaPicker.RECORD_AUDIO_PERMISSION_REQUEST_CODE); - } - - @Override - protected void onRequestPermissionsResult( - final int requestCode, final String permissions[], final int[] grantResults) { - if (requestCode == MediaPicker.RECORD_AUDIO_PERMISSION_REQUEST_CODE) { - final boolean permissionGranted = grantResults[0] == PackageManager.PERMISSION_GRANTED; - mEnabledView.setVisibility(permissionGranted ? View.VISIBLE : View.GONE); - mMissingPermissionView.setVisibility(permissionGranted ? View.GONE : View.VISIBLE); - } - } -} diff --git a/src/com/android/messaging/ui/mediapicker/AudioRecordView.java b/src/com/android/messaging/ui/mediapicker/AudioRecordView.java deleted file mode 100644 index fba493f4..00000000 --- a/src/com/android/messaging/ui/mediapicker/AudioRecordView.java +++ /dev/null @@ -1,351 +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.mediapicker; - -import android.content.Context; -import android.graphics.Color; -import android.graphics.PorterDuff; -import android.graphics.Rect; -import android.graphics.Typeface; -import android.graphics.drawable.Drawable; -import android.graphics.drawable.GradientDrawable; -import android.media.MediaRecorder; -import android.net.Uri; -import android.util.AttributeSet; -import android.view.MotionEvent; -import android.view.View; -import android.widget.FrameLayout; -import android.widget.ImageView; -import android.widget.TextView; - -import com.android.messaging.Factory; -import com.android.messaging.R; -import com.android.messaging.datamodel.data.DraftMessageData.DraftMessageSubscriptionDataProvider; -import com.android.messaging.datamodel.data.MediaPickerMessagePartData; -import com.android.messaging.datamodel.data.MessagePartData; -import com.android.messaging.sms.MmsConfig; -import com.android.messaging.util.Assert; -import com.android.messaging.util.ContentType; -import com.android.messaging.util.LogUtil; -import com.android.messaging.util.MediaUtil; -import com.android.messaging.util.MediaUtil.OnCompletionListener; -import com.android.messaging.util.SafeAsyncTask; -import com.android.messaging.util.ThreadUtil; -import com.android.messaging.util.UiUtils; -import com.google.common.annotations.VisibleForTesting; - -/** - * Hosts an audio recorder with tap and hold to record functionality. - */ -public class AudioRecordView extends FrameLayout implements - MediaRecorder.OnErrorListener, - MediaRecorder.OnInfoListener { - /** - * An interface that communicates with the hosted AudioRecordView. - */ - public interface HostInterface extends DraftMessageSubscriptionDataProvider { - void onAudioRecorded(final MessagePartData item); - } - - /** The initial state, the user may press and hold to start recording */ - private static final int MODE_IDLE = 1; - - /** The user has pressed the record button and we are playing the sound indicating the - * start of recording session. Don't record yet since we don't want the beeping sound - * to get into the recording. */ - private static final int MODE_STARTING = 2; - - /** When the user is actively recording */ - private static final int MODE_RECORDING = 3; - - /** When the user has finished recording, we need to record for some additional time. */ - private static final int MODE_STOPPING = 4; - - // Bug: 16020175: The framework's MediaRecorder would cut off the ending portion of the - // recorded audio by about half a second. To mitigate this issue, we continue the recording - // for some extra time before stopping it. - private static final int AUDIO_RECORD_ENDING_BUFFER_MILLIS = 500; - - /** - * The minimum duration of any recording. Below this threshold, it will be treated as if the - * user clicked the record button and inform the user to tap and hold to record. - */ - private static final int AUDIO_RECORD_MINIMUM_DURATION_MILLIS = 300; - - // For accessibility, the touchable record button is bigger than the record button visual. - private ImageView mRecordButtonVisual; - private View mRecordButton; - private SoundLevels mSoundLevels; - private TextView mHintTextView; - private PausableChronometer mTimerTextView; - private LevelTrackingMediaRecorder mMediaRecorder; - private long mAudioRecordStartTimeMillis; - - private int mCurrentMode = MODE_IDLE; - private HostInterface mHostInterface; - private int mThemeColor; - - public AudioRecordView(final Context context, final AttributeSet attrs) { - super(context, attrs); - mMediaRecorder = new LevelTrackingMediaRecorder(); - } - - public void setHostInterface(final HostInterface hostInterface) { - mHostInterface = hostInterface; - } - - @VisibleForTesting - public void testSetMediaRecorder(final LevelTrackingMediaRecorder recorder) { - mMediaRecorder = recorder; - } - - @Override - protected void onFinishInflate() { - super.onFinishInflate(); - mSoundLevels = (SoundLevels) findViewById(R.id.sound_levels); - mRecordButtonVisual = (ImageView) findViewById(R.id.record_button_visual); - mRecordButton = findViewById(R.id.record_button); - mHintTextView = (TextView) findViewById(R.id.hint_text); - mTimerTextView = (PausableChronometer) findViewById(R.id.timer_text); - mSoundLevels.setLevelSource(mMediaRecorder.getLevelSource()); - mRecordButton.setOnTouchListener(new OnTouchListener() { - @Override - public boolean onTouch(final View v, final MotionEvent event) { - final int action = event.getActionMasked(); - switch (action) { - case MotionEvent.ACTION_DOWN: - onRecordButtonTouchDown(); - - // Don't let the record button handle the down event to let it fall through - // so that we can handle it for the entire panel in onTouchEvent(). This is - // done so that: 1) the user taps on the record button to start recording - // 2) the entire panel owns the touch event so we'd keep recording even - // if the user moves outside the button region. - return false; - } - return false; - } - }); - } - - @Override - public boolean onTouchEvent(final MotionEvent event) { - final int action = event.getActionMasked(); - switch (action) { - case MotionEvent.ACTION_DOWN: - return shouldHandleTouch(); - - case MotionEvent.ACTION_MOVE: - return true; - - case MotionEvent.ACTION_UP: - case MotionEvent.ACTION_CANCEL: - return onRecordButtonTouchUp(); - } - return super.onTouchEvent(event); - } - - public void onPause() { - // The conversation draft cannot take any updates when it's paused. Therefore, forcibly - // stop recording on pause. - stopRecording(); - } - - @Override - protected void onDetachedFromWindow() { - super.onDetachedFromWindow(); - stopRecording(); - } - - private boolean isRecording() { - return mMediaRecorder.isRecording() && mCurrentMode == MODE_RECORDING; - } - - public boolean shouldHandleTouch() { - return mCurrentMode != MODE_IDLE; - } - - public void stopTouchHandling() { - setMode(MODE_IDLE); - stopRecording(); - } - - private void setMode(final int mode) { - if (mCurrentMode != mode) { - mCurrentMode = mode; - updateVisualState(); - } - } - - private void updateVisualState() { - switch (mCurrentMode) { - case MODE_IDLE: - mHintTextView.setVisibility(VISIBLE); - mHintTextView.setTypeface(null, Typeface.NORMAL); - mTimerTextView.setVisibility(GONE); - mSoundLevels.setEnabled(false); - mTimerTextView.stop(); - break; - - case MODE_RECORDING: - case MODE_STOPPING: - mHintTextView.setVisibility(GONE); - mTimerTextView.setVisibility(VISIBLE); - mSoundLevels.setEnabled(true); - mTimerTextView.restart(); - break; - - case MODE_STARTING: - break; // No-Op. - - default: - Assert.fail("invalid mode for AudioRecordView!"); - break; - } - updateRecordButtonAppearance(); - } - - public void setThemeColor(final int color) { - mThemeColor = color; - updateRecordButtonAppearance(); - } - - private void updateRecordButtonAppearance() { - final Drawable foregroundDrawable = getResources().getDrawable(R.drawable.ic_mp_audio_mic); - final GradientDrawable backgroundDrawable = ((GradientDrawable) getResources() - .getDrawable(R.drawable.audio_record_control_button_background)); - if (isRecording()) { - foregroundDrawable.setColorFilter(Color.WHITE, PorterDuff.Mode.SRC_ATOP); - backgroundDrawable.setColor(mThemeColor); - } else { - foregroundDrawable.setColorFilter(mThemeColor, PorterDuff.Mode.SRC_ATOP); - backgroundDrawable.setColor(Color.WHITE); - } - mRecordButtonVisual.setImageDrawable(foregroundDrawable); - mRecordButtonVisual.setBackground(backgroundDrawable); - } - - @VisibleForTesting - boolean onRecordButtonTouchDown() { - if (!mMediaRecorder.isRecording() && mCurrentMode == MODE_IDLE) { - setMode(MODE_STARTING); - playAudioStartSound(new OnCompletionListener() { - @Override - public void onCompletion() { - // Double-check the current mode before recording since the user may have - // lifted finger from the button before the beeping sound is played through. - final int maxSize = MmsConfig.get(mHostInterface.getConversationSelfSubId()) - .getMaxMessageSize(); - if (mCurrentMode == MODE_STARTING && - mMediaRecorder.startRecording(AudioRecordView.this, - AudioRecordView.this, maxSize)) { - setMode(MODE_RECORDING); - } - } - }); - mAudioRecordStartTimeMillis = System.currentTimeMillis(); - return true; - } - return false; - } - - @VisibleForTesting - boolean onRecordButtonTouchUp() { - if (System.currentTimeMillis() - mAudioRecordStartTimeMillis < - AUDIO_RECORD_MINIMUM_DURATION_MILLIS) { - // The recording is too short, bolden the hint text to instruct the user to - // "tap+hold" to record audio. - final Uri outputUri = stopRecording(); - if (outputUri != null) { - SafeAsyncTask.executeOnThreadPool(new Runnable() { - @Override - public void run() { - Factory.get().getApplicationContext().getContentResolver().delete( - outputUri, null, null); - } - }); - } - setMode(MODE_IDLE); - mHintTextView.setTypeface(null, Typeface.BOLD); - } else if (isRecording()) { - // Record for some extra time to ensure the ending part is saved. - setMode(MODE_STOPPING); - ThreadUtil.getMainThreadHandler().postDelayed(new Runnable() { - @Override - public void run() { - onFinishedRecording(); - } - }, AUDIO_RECORD_ENDING_BUFFER_MILLIS); - } else { - setMode(MODE_IDLE); - } - return true; - } - - private Uri stopRecording() { - if (mMediaRecorder.isRecording()) { - return mMediaRecorder.stopRecording(); - } - return null; - } - - @Override // From MediaRecorder.OnInfoListener - public void onInfo(final MediaRecorder mr, final int what, final int extra) { - if (what == MediaRecorder.MEDIA_RECORDER_INFO_MAX_FILESIZE_REACHED) { - // Max size reached. Finish recording immediately. - LogUtil.i(LogUtil.BUGLE_TAG, "Max size reached while recording audio"); - onFinishedRecording(); - } else { - // These are unknown errors. - onErrorWhileRecording(what, extra); - } - } - - @Override // From MediaRecorder.OnErrorListener - public void onError(final MediaRecorder mr, final int what, final int extra) { - onErrorWhileRecording(what, extra); - } - - private void onErrorWhileRecording(final int what, final int extra) { - LogUtil.e(LogUtil.BUGLE_TAG, "Error occurred during audio recording what=" + what + - ", extra=" + extra); - UiUtils.showToastAtBottom(R.string.audio_recording_error); - setMode(MODE_IDLE); - stopRecording(); - } - - private void onFinishedRecording() { - final Uri outputUri = stopRecording(); - if (outputUri != null) { - final Rect startRect = new Rect(); - mRecordButtonVisual.getGlobalVisibleRect(startRect); - final MediaPickerMessagePartData audioItem = - new MediaPickerMessagePartData(startRect, - ContentType.AUDIO_3GPP, outputUri, 0, 0); - mHostInterface.onAudioRecorded(audioItem); - } - playAudioEndSound(); - setMode(MODE_IDLE); - } - - private void playAudioStartSound(final OnCompletionListener completionListener) { - MediaUtil.get().playSound(getContext(), R.raw.audio_initiate, completionListener); - } - - private void playAudioEndSound() { - MediaUtil.get().playSound(getContext(), R.raw.audio_end, null); - } -} diff --git a/src/com/android/messaging/ui/mediapicker/CameraManager.java b/src/com/android/messaging/ui/mediapicker/CameraManager.java deleted file mode 100644 index fb5c9bfc..00000000 --- a/src/com/android/messaging/ui/mediapicker/CameraManager.java +++ /dev/null @@ -1,1201 +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.mediapicker; - -import android.Manifest; -import android.app.Activity; -import android.content.Context; -import android.content.pm.ActivityInfo; -import android.content.res.Configuration; -import android.content.res.Resources; -import android.hardware.Camera; -import android.hardware.Camera.CameraInfo; -import android.media.MediaRecorder; -import android.net.Uri; -import android.os.AsyncTask; -import android.os.Looper; -import androidx.annotation.NonNull; -import android.text.TextUtils; -import android.util.DisplayMetrics; -import android.view.MotionEvent; -import android.view.OrientationEventListener; -import android.view.Surface; -import android.view.View; -import android.view.WindowManager; - -import com.android.messaging.datamodel.data.DraftMessageData.DraftMessageSubscriptionDataProvider; -import com.android.messaging.Factory; -import com.android.messaging.datamodel.data.ParticipantData; -import com.android.messaging.datamodel.media.ImageRequest; -import com.android.messaging.sms.MmsConfig; -import com.android.messaging.ui.mediapicker.camerafocus.FocusOverlayManager; -import com.android.messaging.ui.mediapicker.camerafocus.RenderOverlay; -import com.android.messaging.util.Assert; -import com.android.messaging.util.BugleGservices; -import com.android.messaging.util.BugleGservicesKeys; -import com.android.messaging.util.LogUtil; -import com.android.messaging.util.OsUtil; -import com.android.messaging.util.UiUtils; -import com.google.common.annotations.VisibleForTesting; - -import java.io.FileNotFoundException; -import java.io.IOException; -import java.util.ArrayList; -import java.util.Collections; -import java.util.Comparator; -import java.util.List; - -/** - * Class which manages interactions with the camera, but does not do any UI. This class is - * designed to be a singleton to ensure there is one component managing the camera and releasing - * the native resources. - * In order to acquire a camera, a caller must: - *
    - *
  • Call selectCamera to select front or back camera - *
  • Call setSurface to control where the preview is shown - *
  • Call openCamera to request the camera start preview - *
- * Callers should call onPause and onResume to ensure that the camera is release while the activity - * is not active. - * This class is not thread safe. It should only be called from one thread (the UI thread or test - * thread) - */ -class CameraManager implements FocusOverlayManager.Listener { - /** - * Wrapper around the framework camera API to allow mocking different hardware scenarios while - * unit testing - */ - interface CameraWrapper { - int getNumberOfCameras(); - void getCameraInfo(int index, CameraInfo cameraInfo); - Camera open(int cameraId); - /** Add a wrapper for release because a final method cannot be mocked */ - void release(Camera camera); - } - - /** - * Callbacks for the camera manager listener - */ - interface CameraManagerListener { - void onCameraError(int errorCode, Exception e); - void onCameraChanged(); - } - - /** - * Callback when taking image or video - */ - interface MediaCallback { - static final int MEDIA_CAMERA_CHANGED = 1; - static final int MEDIA_NO_DATA = 2; - - void onMediaReady(Uri uriToMedia, String contentType, int width, int height); - void onMediaFailed(Exception exception); - void onMediaInfo(int what); - } - - // Error codes - static final int ERROR_OPENING_CAMERA = 1; - static final int ERROR_SHOWING_PREVIEW = 2; - static final int ERROR_INITIALIZING_VIDEO = 3; - static final int ERROR_STORAGE_FAILURE = 4; - static final int ERROR_RECORDING_VIDEO = 5; - static final int ERROR_HARDWARE_ACCELERATION_DISABLED = 6; - static final int ERROR_TAKING_PICTURE = 7; - - private static final String TAG = LogUtil.BUGLE_TAG; - private static final int NO_CAMERA_SELECTED = -1; - - private static CameraManager sInstance; - - /** Default camera wrapper which directs calls to the framework APIs */ - private static CameraWrapper sCameraWrapper = new CameraWrapper() { - @Override - public int getNumberOfCameras() { - return Camera.getNumberOfCameras(); - } - - @Override - public void getCameraInfo(final int index, final CameraInfo cameraInfo) { - Camera.getCameraInfo(index, cameraInfo); - } - - @Override - public Camera open(final int cameraId) { - return Camera.open(cameraId); - } - - @Override - public void release(final Camera camera) { - camera.release(); - } - }; - - /** The CameraInfo for the currently selected camera */ - private final CameraInfo mCameraInfo; - - /** - * The index of the selected camera or NO_CAMERA_SELECTED if a camera hasn't been selected yet - */ - private int mCameraIndex; - - /** True if the device has front and back cameras */ - private final boolean mHasFrontAndBackCamera; - - /** True if the camera should be open (may not yet be actually open) */ - private boolean mOpenRequested; - - /** True if the camera is requested to be in video mode */ - private boolean mVideoModeRequested; - - /** The media recorder for video mode */ - private MmsVideoRecorder mMediaRecorder; - - /** Callback to call with video recording updates */ - private MediaCallback mVideoCallback; - - /** The preview view to show the preview on */ - private CameraPreview mCameraPreview; - - /** The helper classs to handle orientation changes */ - private OrientationHandler mOrientationHandler; - - /** Tracks whether the preview has hardware acceleration */ - private boolean mIsHardwareAccelerationSupported; - - /** - * The task for opening the camera, so it doesn't block the UI thread - * Using AsyncTask rather than SafeAsyncTask because the tasks need to be serialized, but don't - * need to be on the UI thread - * TODO: If we have other AyncTasks (not SafeAsyncTasks) this may contend and we may - * need to create a dedicated thread, or synchronize the threads in the thread pool - */ - private AsyncTask mOpenCameraTask; - - /** - * The camera index that is queued to be opened, but not completed yet, or NO_CAMERA_SELECTED if - * no open task is pending - */ - private int mPendingOpenCameraIndex = NO_CAMERA_SELECTED; - - /** The instance of the currently opened camera */ - private Camera mCamera; - - /** The rotation of the screen relative to the camera's natural orientation */ - private int mRotation; - - /** The callback to notify when errors or other events occur */ - private CameraManagerListener mListener; - - /** True if the camera is currently in the process of taking an image */ - private boolean mTakingPicture; - - /** Provides subscription-related data to access per-subscription configurations. */ - private DraftMessageSubscriptionDataProvider mSubscriptionDataProvider; - - /** Manages auto focus visual and behavior */ - private final FocusOverlayManager mFocusOverlayManager; - - private CameraManager() { - mCameraInfo = new CameraInfo(); - mCameraIndex = NO_CAMERA_SELECTED; - - // Check to see if a front and back camera exist - boolean hasFrontCamera = false; - boolean hasBackCamera = false; - final CameraInfo cameraInfo = new CameraInfo(); - final int cameraCount = sCameraWrapper.getNumberOfCameras(); - try { - for (int i = 0; i < cameraCount; i++) { - sCameraWrapper.getCameraInfo(i, cameraInfo); - if (cameraInfo.facing == CameraInfo.CAMERA_FACING_FRONT) { - hasFrontCamera = true; - } else if (cameraInfo.facing == CameraInfo.CAMERA_FACING_BACK) { - hasBackCamera = true; - } - if (hasFrontCamera && hasBackCamera) { - break; - } - } - } catch (final RuntimeException e) { - LogUtil.e(TAG, "Unable to load camera info", e); - } - mHasFrontAndBackCamera = hasFrontCamera && hasBackCamera; - mFocusOverlayManager = new FocusOverlayManager(this, Looper.getMainLooper()); - - // Assume the best until we are proven otherwise - mIsHardwareAccelerationSupported = true; - } - - /** Gets the singleton instance */ - static CameraManager get() { - if (sInstance == null) { - sInstance = new CameraManager(); - } - return sInstance; - } - - /** Allows tests to inject a custom camera wrapper */ - @VisibleForTesting - static void setCameraWrapper(final CameraWrapper cameraWrapper) { - sCameraWrapper = cameraWrapper; - sInstance = null; - } - - /** - * Sets the surface to use to display the preview - * This must only be called AFTER the CameraPreview has a texture ready - * @param preview The preview surface view - */ - void setSurface(final CameraPreview preview) { - if (preview == mCameraPreview) { - return; - } - - if (preview != null) { - Assert.isTrue(preview.isValid()); - preview.setOnTouchListener(new View.OnTouchListener() { - @Override - public boolean onTouch(final View view, final MotionEvent motionEvent) { - if ((motionEvent.getActionMasked() & MotionEvent.ACTION_UP) == - MotionEvent.ACTION_UP) { - mFocusOverlayManager.setPreviewSize(view.getWidth(), view.getHeight()); - mFocusOverlayManager.onSingleTapUp( - (int) motionEvent.getX() + view.getLeft(), - (int) motionEvent.getY() + view.getTop()); - } - return true; - } - }); - } - mCameraPreview = preview; - tryShowPreview(); - } - - void setRenderOverlay(final RenderOverlay renderOverlay) { - mFocusOverlayManager.setFocusRenderer(renderOverlay != null ? - renderOverlay.getPieRenderer() : null); - } - - /** Convenience function to swap between front and back facing cameras */ - void swapCamera() { - Assert.isTrue(mCameraIndex >= 0); - selectCamera(mCameraInfo.facing == CameraInfo.CAMERA_FACING_FRONT ? - CameraInfo.CAMERA_FACING_BACK : - CameraInfo.CAMERA_FACING_FRONT); - } - - /** - * Selects the first camera facing the desired direction, or the first camera if there is no - * camera in the desired direction - * @param desiredFacing One of the CameraInfo.CAMERA_FACING_* constants - * @return True if a camera was selected, or false if selecting a camera failed - */ - boolean selectCamera(final int desiredFacing) { - try { - // We already selected a camera facing that direction - if (mCameraIndex >= 0 && mCameraInfo.facing == desiredFacing) { - return true; - } - - final int cameraCount = sCameraWrapper.getNumberOfCameras(); - Assert.isTrue(cameraCount > 0); - - mCameraIndex = NO_CAMERA_SELECTED; - setCamera(null); - final CameraInfo cameraInfo = new CameraInfo(); - for (int i = 0; i < cameraCount; i++) { - sCameraWrapper.getCameraInfo(i, cameraInfo); - if (cameraInfo.facing == desiredFacing) { - mCameraIndex = i; - sCameraWrapper.getCameraInfo(i, mCameraInfo); - break; - } - } - - // There's no camera in the desired facing direction, just select the first camera - // regardless of direction - if (mCameraIndex < 0) { - mCameraIndex = 0; - sCameraWrapper.getCameraInfo(0, mCameraInfo); - } - - if (mOpenRequested) { - // The camera is open, so reopen with the newly selected camera - openCamera(); - } - return true; - } catch (final RuntimeException e) { - LogUtil.e(TAG, "RuntimeException in CameraManager.selectCamera", e); - if (mListener != null) { - mListener.onCameraError(ERROR_OPENING_CAMERA, e); - } - return false; - } - } - - int getCameraIndex() { - return mCameraIndex; - } - - void selectCameraByIndex(final int cameraIndex) { - if (mCameraIndex == cameraIndex) { - return; - } - - try { - mCameraIndex = cameraIndex; - sCameraWrapper.getCameraInfo(mCameraIndex, mCameraInfo); - if (mOpenRequested) { - openCamera(); - } - } catch (final RuntimeException e) { - LogUtil.e(TAG, "RuntimeException in CameraManager.selectCameraByIndex", e); - if (mListener != null) { - mListener.onCameraError(ERROR_OPENING_CAMERA, e); - } - } - } - - @VisibleForTesting - CameraInfo getCameraInfo() { - if (mCameraIndex == NO_CAMERA_SELECTED) { - return null; - } - return mCameraInfo; - } - - /** @return True if this device has camera capabilities */ - boolean hasAnyCamera() { - return sCameraWrapper.getNumberOfCameras() > 0; - } - - /** @return True if the device has both a front and back camera */ - boolean hasFrontAndBackCamera() { - return mHasFrontAndBackCamera; - } - - /** - * Opens the camera on a separate thread and initiates the preview if one is available - */ - void openCamera() { - if (mCameraIndex == NO_CAMERA_SELECTED) { - // Ensure a selected camera if none is currently selected. This may happen if the - // camera chooser is not the default media chooser. - selectCamera(CameraInfo.CAMERA_FACING_BACK); - } - mOpenRequested = true; - // We're already opening the camera or already have the camera handle, nothing more to do - if (mPendingOpenCameraIndex == mCameraIndex || mCamera != null) { - return; - } - - // True if the task to open the camera has to be delayed until the current one completes - boolean delayTask = false; - - // Cancel any previous open camera tasks - if (mOpenCameraTask != null) { - mPendingOpenCameraIndex = NO_CAMERA_SELECTED; - delayTask = true; - } - - mPendingOpenCameraIndex = mCameraIndex; - mOpenCameraTask = new AsyncTask() { - private Exception mException; - - @Override - protected Camera doInBackground(final Integer... params) { - try { - final int cameraIndex = params[0]; - if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { - LogUtil.v(TAG, "Opening camera " + mCameraIndex); - } - return sCameraWrapper.open(cameraIndex); - } catch (final Exception e) { - LogUtil.e(TAG, "Exception while opening camera", e); - mException = e; - return null; - } - } - - @Override - protected void onPostExecute(final Camera camera) { - // If we completed, but no longer want this camera, then release the camera - if (mOpenCameraTask != this || !mOpenRequested) { - releaseCamera(camera); - cleanup(); - return; - } - - cleanup(); - - if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { - LogUtil.v(TAG, "Opened camera " + mCameraIndex + " " + (camera != null)); - } - - setCamera(camera); - if (camera == null) { - if (mListener != null) { - mListener.onCameraError(ERROR_OPENING_CAMERA, mException); - } - LogUtil.e(TAG, "Error opening camera"); - } - } - - @Override - protected void onCancelled() { - super.onCancelled(); - cleanup(); - } - - private void cleanup() { - mPendingOpenCameraIndex = NO_CAMERA_SELECTED; - if (mOpenCameraTask != null && mOpenCameraTask.getStatus() == Status.PENDING) { - // If there's another task waiting on this one to complete, start it now - mOpenCameraTask.execute(mCameraIndex); - } else { - mOpenCameraTask = null; - } - - } - }; - if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { - LogUtil.v(TAG, "Start opening camera " + mCameraIndex); - } - - if (!delayTask) { - mOpenCameraTask.execute(mCameraIndex); - } - } - - boolean isVideoMode() { - return mVideoModeRequested; - } - - boolean isRecording() { - return mVideoModeRequested && mVideoCallback != null; - } - - void setVideoMode(final boolean videoMode) { - if (mVideoModeRequested == videoMode) { - return; - } - mVideoModeRequested = videoMode; - tryInitOrCleanupVideoMode(); - } - - /** Closes the camera releasing the resources it uses */ - void closeCamera() { - mOpenRequested = false; - setCamera(null); - } - - /** Temporarily closes the camera if it is open */ - void onPause() { - setCamera(null); - } - - /** Reopens the camera if it was opened when onPause was called */ - void onResume() { - if (mOpenRequested) { - openCamera(); - } - } - - /** - * Sets the listener which will be notified of errors or other events in the camera - * @param listener The listener to notify - */ - void setListener(final CameraManagerListener listener) { - Assert.isMainThread(); - mListener = listener; - if (!mIsHardwareAccelerationSupported && mListener != null) { - mListener.onCameraError(ERROR_HARDWARE_ACCELERATION_DISABLED, null); - } - } - - void setSubscriptionDataProvider(final DraftMessageSubscriptionDataProvider provider) { - mSubscriptionDataProvider = provider; - } - - void takePicture(final float heightPercent, @NonNull final MediaCallback callback) { - Assert.isTrue(!mVideoModeRequested); - Assert.isTrue(!mTakingPicture); - Assert.notNull(callback); - if (mCamera == null) { - // The caller should have checked isCameraAvailable first, but just in case, protect - // against a null camera by notifying the callback that taking the picture didn't work - callback.onMediaFailed(null); - return; - } - final Camera.PictureCallback jpegCallback = new Camera.PictureCallback() { - @Override - public void onPictureTaken(final byte[] bytes, final Camera camera) { - mTakingPicture = false; - if (mCamera != camera) { - // This may happen if the camera was changed between front/back while the - // picture is being taken. - callback.onMediaInfo(MediaCallback.MEDIA_CAMERA_CHANGED); - return; - } - - if (bytes == null) { - callback.onMediaInfo(MediaCallback.MEDIA_NO_DATA); - return; - } - - final Camera.Size size = camera.getParameters().getPictureSize(); - int width; - int height; - if (mRotation == 90 || mRotation == 270) { - width = size.height; - height = size.width; - } else { - width = size.width; - height = size.height; - } - new ImagePersistTask( - width, height, heightPercent, bytes, mCameraPreview.getContext(), callback) - .executeOnThreadPool(); - } - }; - - mTakingPicture = true; - try { - mCamera.takePicture( - null /* shutter */, - null /* raw */, - null /* postView */, - jpegCallback); - } catch (final RuntimeException e) { - LogUtil.e(TAG, "RuntimeException in CameraManager.takePicture", e); - mTakingPicture = false; - if (mListener != null) { - mListener.onCameraError(ERROR_TAKING_PICTURE, e); - } - } - } - - void startVideo(final MediaCallback callback) { - Assert.notNull(callback); - Assert.isTrue(!isRecording()); - mVideoCallback = callback; - tryStartVideoCapture(); - } - - /** - * Asynchronously releases a camera - * @param camera The camera to release - */ - private void releaseCamera(final Camera camera) { - if (camera == null) { - return; - } - - mFocusOverlayManager.onCameraReleased(); - - new AsyncTask() { - @Override - protected Void doInBackground(final Void... params) { - if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { - LogUtil.v(TAG, "Releasing camera " + mCameraIndex); - } - sCameraWrapper.release(camera); - return null; - } - }.execute(); - } - - private void releaseMediaRecorder(final boolean cleanupFile) { - if (mMediaRecorder == null) { - return; - } - mVideoModeRequested = false; - - if (cleanupFile) { - mMediaRecorder.cleanupTempFile(); - if (mVideoCallback != null) { - final MediaCallback callback = mVideoCallback; - mVideoCallback = null; - // Notify the callback that we've stopped recording - callback.onMediaReady(null /*uri*/, null /*contentType*/, 0 /*width*/, - 0 /*height*/); - } - } - - mMediaRecorder.closeVideoFileDescriptor(); - mMediaRecorder.release(); - mMediaRecorder = null; - - if (mCamera != null) { - try { - mCamera.reconnect(); - } catch (final IOException e) { - LogUtil.e(TAG, "IOException in CameraManager.releaseMediaRecorder", e); - if (mListener != null) { - mListener.onCameraError(ERROR_OPENING_CAMERA, e); - } - } catch (final RuntimeException e) { - LogUtil.e(TAG, "RuntimeException in CameraManager.releaseMediaRecorder", e); - if (mListener != null) { - mListener.onCameraError(ERROR_OPENING_CAMERA, e); - } - } - } - restoreRequestedOrientation(); - } - - /** Updates the orientation of the camera to match the orientation of the device */ - private void updateCameraOrientation() { - if (mCamera == null || mCameraPreview == null || mTakingPicture) { - return; - } - - final WindowManager windowManager = - (WindowManager) mCameraPreview.getContext().getSystemService( - Context.WINDOW_SERVICE); - - int degrees = 0; - switch (windowManager.getDefaultDisplay().getRotation()) { - case Surface.ROTATION_0: degrees = 0; break; - case Surface.ROTATION_90: degrees = 90; break; - case Surface.ROTATION_180: degrees = 180; break; - case Surface.ROTATION_270: degrees = 270; break; - } - - // The display orientation of the camera (this controls the preview image). - int orientation; - - // The clockwise rotation angle relative to the orientation of the camera. This affects - // pictures returned by the camera in Camera.PictureCallback. - int rotation; - if (mCameraInfo.facing == Camera.CameraInfo.CAMERA_FACING_FRONT) { - orientation = (mCameraInfo.orientation + degrees) % 360; - rotation = orientation; - // compensate the mirror but only for orientation - orientation = (360 - orientation) % 360; - } else { // back-facing - orientation = (mCameraInfo.orientation - degrees + 360) % 360; - rotation = orientation; - } - mRotation = rotation; - if (mMediaRecorder == null) { - try { - mCamera.setDisplayOrientation(orientation); - final Camera.Parameters params = mCamera.getParameters(); - params.setRotation(rotation); - mCamera.setParameters(params); - } catch (final RuntimeException e) { - LogUtil.e(TAG, "RuntimeException in CameraManager.updateCameraOrientation", e); - if (mListener != null) { - mListener.onCameraError(ERROR_OPENING_CAMERA, e); - } - } - } - } - - /** Sets the current camera, releasing any previously opened camera */ - private void setCamera(final Camera camera) { - if (mCamera == camera) { - return; - } - - releaseMediaRecorder(true /* cleanupFile */); - releaseCamera(mCamera); - mCamera = camera; - tryShowPreview(); - if (mListener != null) { - mListener.onCameraChanged(); - } - } - - /** Shows the preview if the camera is open and the preview is loaded */ - private void tryShowPreview() { - if (mCameraPreview == null || mCamera == null) { - if (mOrientationHandler != null) { - mOrientationHandler.disable(); - mOrientationHandler = null; - } - releaseMediaRecorder(true /* cleanupFile */); - mFocusOverlayManager.onPreviewStopped(); - return; - } - try { - mCamera.stopPreview(); - updateCameraOrientation(); - - final Camera.Parameters params = mCamera.getParameters(); - final Camera.Size pictureSize = chooseBestPictureSize(); - final Camera.Size previewSize = chooseBestPreviewSize(pictureSize); - params.setPreviewSize(previewSize.width, previewSize.height); - params.setPictureSize(pictureSize.width, pictureSize.height); - logCameraSize("Setting preview size: ", previewSize); - logCameraSize("Setting picture size: ", pictureSize); - mCameraPreview.setSize(previewSize, mCameraInfo.orientation); - for (final String focusMode : params.getSupportedFocusModes()) { - if (TextUtils.equals(focusMode, Camera.Parameters.FOCUS_MODE_CONTINUOUS_PICTURE)) { - // Use continuous focus if available - params.setFocusMode(focusMode); - break; - } - } - - mCamera.setParameters(params); - mCameraPreview.startPreview(mCamera); - mCamera.startPreview(); - mCamera.setAutoFocusMoveCallback(new Camera.AutoFocusMoveCallback() { - @Override - public void onAutoFocusMoving(final boolean start, final Camera camera) { - mFocusOverlayManager.onAutoFocusMoving(start); - } - }); - mFocusOverlayManager.setParameters(mCamera.getParameters()); - mFocusOverlayManager.setMirror(mCameraInfo.facing == CameraInfo.CAMERA_FACING_BACK); - mFocusOverlayManager.onPreviewStarted(); - tryInitOrCleanupVideoMode(); - if (mOrientationHandler == null) { - mOrientationHandler = new OrientationHandler(mCameraPreview.getContext()); - mOrientationHandler.enable(); - } - } catch (final IOException e) { - LogUtil.e(TAG, "IOException in CameraManager.tryShowPreview", e); - if (mListener != null) { - mListener.onCameraError(ERROR_SHOWING_PREVIEW, e); - } - } catch (final RuntimeException e) { - LogUtil.e(TAG, "RuntimeException in CameraManager.tryShowPreview", e); - if (mListener != null) { - mListener.onCameraError(ERROR_SHOWING_PREVIEW, e); - } - } - } - - private void tryInitOrCleanupVideoMode() { - if (!mVideoModeRequested || mCamera == null || mCameraPreview == null) { - releaseMediaRecorder(true /* cleanupFile */); - return; - } - - if (mMediaRecorder != null) { - return; - } - - try { - mCamera.unlock(); - final int maxMessageSize = getMmsConfig().getMaxMessageSize(); - mMediaRecorder = new MmsVideoRecorder(mCamera, mCameraIndex, mRotation, maxMessageSize); - mMediaRecorder.prepare(); - } catch (final FileNotFoundException e) { - LogUtil.e(TAG, "FileNotFoundException in CameraManager.tryInitOrCleanupVideoMode", e); - if (mListener != null) { - mListener.onCameraError(ERROR_STORAGE_FAILURE, e); - } - setVideoMode(false); - return; - } catch (final IOException e) { - LogUtil.e(TAG, "IOException in CameraManager.tryInitOrCleanupVideoMode", e); - if (mListener != null) { - mListener.onCameraError(ERROR_INITIALIZING_VIDEO, e); - } - setVideoMode(false); - return; - } catch (final RuntimeException e) { - LogUtil.e(TAG, "RuntimeException in CameraManager.tryInitOrCleanupVideoMode", e); - if (mListener != null) { - mListener.onCameraError(ERROR_INITIALIZING_VIDEO, e); - } - setVideoMode(false); - return; - } - - tryStartVideoCapture(); - } - - private void tryStartVideoCapture() { - if (mMediaRecorder == null || mVideoCallback == null) { - return; - } - - mMediaRecorder.setOnErrorListener(new MediaRecorder.OnErrorListener() { - @Override - public void onError(final MediaRecorder mediaRecorder, final int what, - final int extra) { - if (mListener != null) { - mListener.onCameraError(ERROR_RECORDING_VIDEO, null); - } - restoreRequestedOrientation(); - } - }); - - mMediaRecorder.setOnInfoListener(new MediaRecorder.OnInfoListener() { - @Override - public void onInfo(final MediaRecorder mediaRecorder, final int what, final int extra) { - if (what == MediaRecorder.MEDIA_RECORDER_INFO_MAX_DURATION_REACHED || - what == MediaRecorder.MEDIA_RECORDER_INFO_MAX_FILESIZE_REACHED) { - stopVideo(); - } - } - }); - - try { - mMediaRecorder.start(); - final Activity activity = UiUtils.getActivity(mCameraPreview.getContext()); - activity.getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); - lockOrientation(); - } catch (final IllegalStateException e) { - LogUtil.e(TAG, "IllegalStateException in CameraManager.tryStartVideoCapture", e); - if (mListener != null) { - mListener.onCameraError(ERROR_RECORDING_VIDEO, e); - } - setVideoMode(false); - restoreRequestedOrientation(); - } catch (final RuntimeException e) { - LogUtil.e(TAG, "RuntimeException in CameraManager.tryStartVideoCapture", e); - if (mListener != null) { - mListener.onCameraError(ERROR_RECORDING_VIDEO, e); - } - setVideoMode(false); - restoreRequestedOrientation(); - } - } - - void stopVideo() { - int width = ImageRequest.UNSPECIFIED_SIZE; - int height = ImageRequest.UNSPECIFIED_SIZE; - Uri uri = null; - String contentType = null; - try { - final Activity activity = UiUtils.getActivity(mCameraPreview.getContext()); - activity.getWindow().clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); - mMediaRecorder.stop(); - width = mMediaRecorder.getVideoWidth(); - height = mMediaRecorder.getVideoHeight(); - uri = mMediaRecorder.getVideoUri(); - contentType = mMediaRecorder.getContentType(); - } catch (final RuntimeException e) { - // MediaRecorder.stop will throw a RuntimeException if the video was too short, let the - // finally clause call the callback with null uri and handle cleanup - LogUtil.e(TAG, "RuntimeException in CameraManager.stopVideo", e); - } finally { - final MediaCallback videoCallback = mVideoCallback; - mVideoCallback = null; - releaseMediaRecorder(false /* cleanupFile */); - if (uri == null) { - tryInitOrCleanupVideoMode(); - } - videoCallback.onMediaReady(uri, contentType, width, height); - } - } - - boolean isCameraAvailable() { - return mCamera != null && !mTakingPicture && mIsHardwareAccelerationSupported; - } - - /** - * External components call into this to report if hardware acceleration is supported. When - * hardware acceleration isn't supported, we need to report an error through the listener - * interface - * @param isHardwareAccelerationSupported True if the preview is rendering in a hardware - * accelerated view. - */ - void reportHardwareAccelerationSupported(final boolean isHardwareAccelerationSupported) { - Assert.isMainThread(); - if (mIsHardwareAccelerationSupported == isHardwareAccelerationSupported) { - // If the value hasn't changed nothing more to do - return; - } - - mIsHardwareAccelerationSupported = isHardwareAccelerationSupported; - if (!isHardwareAccelerationSupported) { - LogUtil.e(TAG, "Software rendering - cannot open camera"); - if (mListener != null) { - mListener.onCameraError(ERROR_HARDWARE_ACCELERATION_DISABLED, null); - } - } - } - - /** Returns the scale factor to scale the width/height to max allowed in MmsConfig */ - private float getScaleFactorForMaxAllowedSize(final int width, final int height, - final int maxWidth, final int maxHeight) { - if (maxWidth <= 0 || maxHeight <= 0) { - // MmsConfig initialization runs asynchronously on application startup, so there's a - // chance (albeit a very slight one) that we don't have it yet. - LogUtil.w(LogUtil.BUGLE_TAG, "Max image size not loaded in MmsConfig"); - return 1.0f; - } - - if (width <= maxWidth && height <= maxHeight) { - // Already meeting requirements. - return 1.0f; - } - - return Math.min(maxWidth * 1.0f / width, maxHeight * 1.0f / height); - } - - private MmsConfig getMmsConfig() { - final int subId = mSubscriptionDataProvider != null ? - mSubscriptionDataProvider.getConversationSelfSubId() : - ParticipantData.DEFAULT_SELF_SUB_ID; - return MmsConfig.get(subId); - } - - /** - * Choose the best picture size by trying to find a size close to the MmsConfig's max size, - * which is closest to the screen aspect ratio - */ - private Camera.Size chooseBestPictureSize() { - final Context context = mCameraPreview.getContext(); - final Resources resources = context.getResources(); - final DisplayMetrics displayMetrics = resources.getDisplayMetrics(); - final int displayOrientation = resources.getConfiguration().orientation; - int cameraOrientation = mCameraInfo.orientation; - - int screenWidth; - int screenHeight; - if (displayOrientation == Configuration.ORIENTATION_LANDSCAPE) { - // Rotate the camera orientation 90 degrees to compensate for the rotated display - // metrics. Direction doesn't matter because we're just using it for width/height - cameraOrientation += 90; - } - - // Check the camera orientation relative to the display. - // For 0, 180, 360, the screen width/height are the display width/height - // For 90, 270, the screen width/height are inverted from the display - if (cameraOrientation % 180 == 0) { - screenWidth = displayMetrics.widthPixels; - screenHeight = displayMetrics.heightPixels; - } else { - screenWidth = displayMetrics.heightPixels; - screenHeight = displayMetrics.widthPixels; - } - - final MmsConfig mmsConfig = getMmsConfig(); - final int maxWidth = mmsConfig.getMaxImageWidth(); - final int maxHeight = mmsConfig.getMaxImageHeight(); - - // Constrain the size within the max width/height defined by MmsConfig. - final float scaleFactor = getScaleFactorForMaxAllowedSize(screenWidth, screenHeight, - maxWidth, maxHeight); - screenWidth *= scaleFactor; - screenHeight *= scaleFactor; - - final float aspectRatio = BugleGservices.get().getFloat( - BugleGservicesKeys.CAMERA_ASPECT_RATIO, - screenWidth / (float) screenHeight); - final List sizes = new ArrayList( - mCamera.getParameters().getSupportedPictureSizes()); - final int maxPixels = maxWidth * maxHeight; - - // Sort the sizes so the best size is first - Collections.sort(sizes, new SizeComparator(maxWidth, maxHeight, aspectRatio, maxPixels)); - - return sizes.get(0); - } - - /** - * Chose the best preview size based on the picture size. Try to find a size with the same - * aspect ratio and size as the picture if possible - */ - private Camera.Size chooseBestPreviewSize(final Camera.Size pictureSize) { - final List sizes = new ArrayList( - mCamera.getParameters().getSupportedPreviewSizes()); - final float aspectRatio = pictureSize.width / (float) pictureSize.height; - final int capturePixels = pictureSize.width * pictureSize.height; - - // Sort the sizes so the best size is first - Collections.sort(sizes, new SizeComparator(Integer.MAX_VALUE, Integer.MAX_VALUE, - aspectRatio, capturePixels)); - - return sizes.get(0); - } - - private class OrientationHandler extends OrientationEventListener { - OrientationHandler(final Context context) { - super(context); - } - - @Override - public void onOrientationChanged(final int orientation) { - updateCameraOrientation(); - } - } - - private static class SizeComparator implements Comparator { - private static final int PREFER_LEFT = -1; - private static final int PREFER_RIGHT = 1; - - // The max width/height for the preferred size. Integer.MAX_VALUE if no size limit - private final int mMaxWidth; - private final int mMaxHeight; - - // The desired aspect ratio - private final float mTargetAspectRatio; - - // The desired size (width x height) to try to match - private final int mTargetPixels; - - public SizeComparator(final int maxWidth, final int maxHeight, - final float targetAspectRatio, final int targetPixels) { - mMaxWidth = maxWidth; - mMaxHeight = maxHeight; - mTargetAspectRatio = targetAspectRatio; - mTargetPixels = targetPixels; - } - - /** - * Returns a negative value if left is a better choice than right, or a positive value if - * right is a better choice is better than left. 0 if they are equal - */ - @Override - public int compare(final Camera.Size left, final Camera.Size right) { - // If one size is less than the max size prefer it over the other - if ((left.width <= mMaxWidth && left.height <= mMaxHeight) != - (right.width <= mMaxWidth && right.height <= mMaxHeight)) { - return left.width <= mMaxWidth ? PREFER_LEFT : PREFER_RIGHT; - } - - // If one is closer to the target aspect ratio, prefer it. - final float leftAspectRatio = left.width / (float) left.height; - final float rightAspectRatio = right.width / (float) right.height; - final float leftAspectRatioDiff = Math.abs(leftAspectRatio - mTargetAspectRatio); - final float rightAspectRatioDiff = Math.abs(rightAspectRatio - mTargetAspectRatio); - if (leftAspectRatioDiff != rightAspectRatioDiff) { - return (leftAspectRatioDiff - rightAspectRatioDiff) < 0 ? - PREFER_LEFT : PREFER_RIGHT; - } - - // At this point they have the same aspect ratio diff and are either both bigger - // than the max size or both smaller than the max size, so prefer the one closest - // to target size - final int leftDiff = Math.abs((left.width * left.height) - mTargetPixels); - final int rightDiff = Math.abs((right.width * right.height) - mTargetPixels); - return leftDiff - rightDiff; - } - } - - @Override // From FocusOverlayManager.Listener - public void autoFocus() { - if (mCamera == null) { - return; - } - - try { - mCamera.autoFocus(new Camera.AutoFocusCallback() { - @Override - public void onAutoFocus(final boolean success, final Camera camera) { - mFocusOverlayManager.onAutoFocus(success, false /* shutterDown */); - } - }); - } catch (final RuntimeException e) { - LogUtil.e(TAG, "RuntimeException in CameraManager.autoFocus", e); - // If autofocus fails, the camera should have called the callback with success=false, - // but some throw an exception here - mFocusOverlayManager.onAutoFocus(false /*success*/, false /*shutterDown*/); - } - } - - @Override // From FocusOverlayManager.Listener - public void cancelAutoFocus() { - if (mCamera == null) { - return; - } - try { - mCamera.cancelAutoFocus(); - } catch (final RuntimeException e) { - // Ignore - LogUtil.e(TAG, "RuntimeException in CameraManager.cancelAutoFocus", e); - } - } - - @Override // From FocusOverlayManager.Listener - public boolean capture() { - return false; - } - - @Override // From FocusOverlayManager.Listener - public void setFocusParameters() { - if (mCamera == null) { - return; - } - try { - final Camera.Parameters parameters = mCamera.getParameters(); - parameters.setFocusMode(mFocusOverlayManager.getFocusMode()); - if (parameters.getMaxNumFocusAreas() > 0) { - // Don't set focus areas (even to null) if focus areas aren't supported, camera may - // crash - parameters.setFocusAreas(mFocusOverlayManager.getFocusAreas()); - } - parameters.setMeteringAreas(mFocusOverlayManager.getMeteringAreas()); - mCamera.setParameters(parameters); - } catch (final RuntimeException e) { - // This occurs when the device is out of space or when the camera is locked - LogUtil.e(TAG, "RuntimeException in CameraManager setFocusParameters"); - } - } - - private void logCameraSize(final String prefix, final Camera.Size size) { - // Log the camera size and aspect ratio for help when examining bug reports for camera - // failures - LogUtil.i(TAG, prefix + size.width + "x" + size.height + - " (" + (size.width / (float) size.height) + ")"); - } - - - private Integer mSavedOrientation = null; - - private void lockOrientation() { - // when we start recording, lock our orientation - final Activity a = UiUtils.getActivity(mCameraPreview.getContext()); - final WindowManager windowManager = - (WindowManager) a.getSystemService(Context.WINDOW_SERVICE); - final int rotation = windowManager.getDefaultDisplay().getRotation(); - - mSavedOrientation = a.getRequestedOrientation(); - switch (rotation) { - case Surface.ROTATION_0: - a.setRequestedOrientation( - ActivityInfo.SCREEN_ORIENTATION_PORTRAIT); - break; - case Surface.ROTATION_90: - a.setRequestedOrientation( - ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE); - break; - case Surface.ROTATION_180: - a.setRequestedOrientation( - ActivityInfo.SCREEN_ORIENTATION_REVERSE_PORTRAIT); - break; - case Surface.ROTATION_270: - a.setRequestedOrientation( - ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE); - break; - } - - } - - private void restoreRequestedOrientation() { - if (mSavedOrientation != null) { - final Activity a = UiUtils.getActivity(mCameraPreview.getContext()); - if (a != null) { - a.setRequestedOrientation(mSavedOrientation); - } - mSavedOrientation = null; - } - } - - static boolean hasCameraPermission() { - return OsUtil.hasPermission(Manifest.permission.CAMERA); - } -} diff --git a/src/com/android/messaging/ui/mediapicker/CameraMediaChooser.java b/src/com/android/messaging/ui/mediapicker/CameraMediaChooser.java deleted file mode 100644 index 2c7a7f2f..00000000 --- a/src/com/android/messaging/ui/mediapicker/CameraMediaChooser.java +++ /dev/null @@ -1,481 +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.mediapicker; - -import android.Manifest; -import android.content.Context; -import android.content.pm.PackageManager; -import android.graphics.Rect; -import android.hardware.Camera; -import android.net.Uri; -import android.os.SystemClock; -import android.view.LayoutInflater; -import android.view.MotionEvent; -import android.view.View; -import android.view.ViewGroup; -import android.view.animation.AlphaAnimation; -import android.view.animation.Animation; -import android.view.animation.AnimationSet; -import android.widget.Chronometer; -import android.widget.ImageButton; - -import com.android.messaging.R; -import com.android.messaging.datamodel.data.MediaPickerMessagePartData; -import com.android.messaging.ui.mediapicker.CameraManager.MediaCallback; -import com.android.messaging.ui.mediapicker.camerafocus.RenderOverlay; -import com.android.messaging.util.Assert; -import com.android.messaging.util.LogUtil; -import com.android.messaging.util.OsUtil; -import com.android.messaging.util.UiUtils; - -/** - * Chooser which allows the user to take pictures or video without leaving the current app/activity - */ -class CameraMediaChooser extends MediaChooser implements - CameraManager.CameraManagerListener { - private CameraPreview.CameraPreviewHost mCameraPreviewHost; - private ImageButton mFullScreenButton; - private ImageButton mSwapCameraButton; - private ImageButton mSwapModeButton; - private ImageButton mCaptureButton; - private ImageButton mCancelVideoButton; - private Chronometer mVideoCounter; - private boolean mVideoCancelled; - private int mErrorToast; - private View mEnabledView; - private View mMissingPermissionView; - - CameraMediaChooser(final MediaPicker mediaPicker) { - super(mediaPicker); - } - - @Override - public int getSupportedMediaTypes() { - if (CameraManager.get().hasAnyCamera()) { - return MediaPicker.MEDIA_TYPE_IMAGE | MediaPicker.MEDIA_TYPE_VIDEO; - } else { - return MediaPicker.MEDIA_TYPE_NONE; - } - } - - @Override - public View destroyView() { - CameraManager.get().closeCamera(); - CameraManager.get().setListener(null); - CameraManager.get().setSubscriptionDataProvider(null); - return super.destroyView(); - } - - @Override - protected View createView(final ViewGroup container) { - CameraManager.get().setListener(this); - CameraManager.get().setSubscriptionDataProvider(this); - CameraManager.get().setVideoMode(false); - final LayoutInflater inflater = getLayoutInflater(); - final CameraMediaChooserView view = (CameraMediaChooserView) inflater.inflate( - R.layout.mediapicker_camera_chooser, - container /* root */, - false /* attachToRoot */); - mCameraPreviewHost = (CameraPreview.CameraPreviewHost) view.findViewById( - R.id.camera_preview); - mCameraPreviewHost.getView().setOnTouchListener(new View.OnTouchListener() { - @Override - public boolean onTouch(final View view, final MotionEvent motionEvent) { - if (CameraManager.get().isVideoMode()) { - // Prevent the swipe down in video mode because video is always captured in - // full screen - return true; - } - - return false; - } - }); - - final View shutterVisual = view.findViewById(R.id.camera_shutter_visual); - - mFullScreenButton = (ImageButton) view.findViewById(R.id.camera_fullScreen_button); - mFullScreenButton.setOnClickListener(new View.OnClickListener() { - @Override - public void onClick(final View view) { - mMediaPicker.setFullScreen(true); - } - }); - - mSwapCameraButton = (ImageButton) view.findViewById(R.id.camera_swapCamera_button); - mSwapCameraButton.setOnClickListener(new View.OnClickListener() { - @Override - public void onClick(final View view) { - CameraManager.get().swapCamera(); - } - }); - - mCaptureButton = (ImageButton) view.findViewById(R.id.camera_capture_button); - mCaptureButton.setOnClickListener(new View.OnClickListener() { - @Override - public void onClick(final View v) { - final float heightPercent = Math.min(mMediaPicker.getViewPager().getHeight() / - (float) mCameraPreviewHost.getView().getHeight(), 1); - - if (CameraManager.get().isRecording()) { - CameraManager.get().stopVideo(); - } else { - final CameraManager.MediaCallback callback = new CameraManager.MediaCallback() { - @Override - public void onMediaReady( - final Uri uriToVideo, final String contentType, - final int width, final int height) { - mVideoCounter.stop(); - if (mVideoCancelled || uriToVideo == null) { - mVideoCancelled = false; - } else { - final Rect startRect = new Rect(); - // It's possible to throw out the chooser while taking the - // picture/video. In that case, still use the attachment, just - // skip the startRect - if (mView != null) { - mView.getGlobalVisibleRect(startRect); - } - mMediaPicker.dispatchItemsSelected( - new MediaPickerMessagePartData(startRect, contentType, - uriToVideo, width, height), - true /* dismissMediaPicker */); - } - updateViewState(); - } - - @Override - public void onMediaFailed(final Exception exception) { - UiUtils.showToastAtBottom(R.string.camera_media_failure); - updateViewState(); - } - - @Override - public void onMediaInfo(final int what) { - if (what == MediaCallback.MEDIA_NO_DATA) { - UiUtils.showToastAtBottom(R.string.camera_media_failure); - } - updateViewState(); - } - }; - if (CameraManager.get().isVideoMode()) { - CameraManager.get().startVideo(callback); - mVideoCounter.setBase(SystemClock.elapsedRealtime()); - mVideoCounter.start(); - updateViewState(); - } else { - showShutterEffect(shutterVisual); - CameraManager.get().takePicture(heightPercent, callback); - updateViewState(); - } - } - } - }); - - mSwapModeButton = (ImageButton) view.findViewById(R.id.camera_swap_mode_button); - mSwapModeButton.setOnClickListener(new View.OnClickListener() { - @Override - public void onClick(final View view) { - final boolean isSwitchingToVideo = !CameraManager.get().isVideoMode(); - if (isSwitchingToVideo && !OsUtil.hasRecordAudioPermission()) { - requestRecordAudioPermission(); - } else { - onSwapMode(); - } - } - }); - - mCancelVideoButton = (ImageButton) view.findViewById(R.id.camera_cancel_button); - mCancelVideoButton.setOnClickListener(new View.OnClickListener() { - @Override - public void onClick(final View view) { - mVideoCancelled = true; - CameraManager.get().stopVideo(); - mMediaPicker.dismiss(true); - } - }); - - mVideoCounter = (Chronometer) view.findViewById(R.id.camera_video_counter); - - CameraManager.get().setRenderOverlay((RenderOverlay) view.findViewById(R.id.focus_visual)); - - mEnabledView = view.findViewById(R.id.mediapicker_enabled); - mMissingPermissionView = view.findViewById(R.id.missing_permission_view); - - // Must set mView before calling updateViewState because it operates on mView - mView = view; - updateViewState(); - updateForPermissionState(CameraManager.hasCameraPermission()); - return view; - } - - @Override - public int getIconResource() { - return R.drawable.ic_camera_light; - } - - @Override - public int getIconDescriptionResource() { - return R.string.mediapicker_cameraChooserDescription; - } - - /** - * Updates the view when entering or leaving full-screen camera mode - * @param fullScreen - */ - @Override - void onFullScreenChanged(final boolean fullScreen) { - super.onFullScreenChanged(fullScreen); - if (!fullScreen && CameraManager.get().isVideoMode()) { - CameraManager.get().setVideoMode(false); - } - updateViewState(); - } - - /** - * Initializes the control to a default state when it is opened / closed - * @param open True if the control is opened - */ - @Override - void onOpenedChanged(final boolean open) { - super.onOpenedChanged(open); - updateViewState(); - } - - @Override - protected void setSelected(final boolean selected) { - super.setSelected(selected); - if (selected) { - if (CameraManager.hasCameraPermission()) { - // If an error occurred before the chooser was selected, show it now - showErrorToastIfNeeded(); - } else { - requestCameraPermission(); - } - } - } - - private void requestCameraPermission() { - mMediaPicker.requestPermissions(new String[] { Manifest.permission.CAMERA }, - MediaPicker.CAMERA_PERMISSION_REQUEST_CODE); - } - - private void requestRecordAudioPermission() { - mMediaPicker.requestPermissions(new String[] { Manifest.permission.RECORD_AUDIO }, - MediaPicker.RECORD_AUDIO_PERMISSION_REQUEST_CODE); - } - - @Override - protected void onRequestPermissionsResult( - final int requestCode, final String permissions[], final int[] grantResults) { - if (requestCode == MediaPicker.CAMERA_PERMISSION_REQUEST_CODE) { - final boolean permissionGranted = grantResults[0] == PackageManager.PERMISSION_GRANTED; - updateForPermissionState(permissionGranted); - if (permissionGranted) { - mCameraPreviewHost.onCameraPermissionGranted(); - } - } else if (requestCode == MediaPicker.RECORD_AUDIO_PERMISSION_REQUEST_CODE) { - Assert.isFalse(CameraManager.get().isVideoMode()); - if (grantResults[0] == PackageManager.PERMISSION_GRANTED) { - // Switch to video mode - onSwapMode(); - } else { - // Stay in still-photo mode - } - } - } - - private void updateForPermissionState(final boolean granted) { - // onRequestPermissionsResult can sometimes get called before createView(). - if (mEnabledView == null) { - return; - } - - mEnabledView.setVisibility(granted ? View.VISIBLE : View.GONE); - mMissingPermissionView.setVisibility(granted ? View.GONE : View.VISIBLE); - } - - @Override - public boolean canSwipeDown() { - if (CameraManager.get().isVideoMode()) { - return true; - } - return super.canSwipeDown(); - } - - /** - * Handles an error from the camera manager by showing the appropriate error message to the user - * @param errorCode One of the CameraManager.ERROR_* constants - * @param e The exception which caused the error, if any - */ - @Override - public void onCameraError(final int errorCode, final Exception e) { - switch (errorCode) { - case CameraManager.ERROR_OPENING_CAMERA: - case CameraManager.ERROR_SHOWING_PREVIEW: - mErrorToast = R.string.camera_error_opening; - break; - case CameraManager.ERROR_INITIALIZING_VIDEO: - mErrorToast = R.string.camera_error_video_init_fail; - updateViewState(); - break; - case CameraManager.ERROR_STORAGE_FAILURE: - mErrorToast = R.string.camera_error_storage_fail; - updateViewState(); - break; - case CameraManager.ERROR_TAKING_PICTURE: - mErrorToast = R.string.camera_error_failure_taking_picture; - break; - default: - mErrorToast = R.string.camera_error_unknown; - LogUtil.w(LogUtil.BUGLE_TAG, "Unknown camera error:" + errorCode); - break; - } - showErrorToastIfNeeded(); - } - - private void showErrorToastIfNeeded() { - if (mErrorToast != 0 && mSelected) { - UiUtils.showToastAtBottom(mErrorToast); - mErrorToast = 0; - } - } - - @Override - public void onCameraChanged() { - updateViewState(); - } - - private void onSwapMode() { - CameraManager.get().setVideoMode(!CameraManager.get().isVideoMode()); - if (CameraManager.get().isVideoMode()) { - mMediaPicker.setFullScreen(true); - - // For now we start recording immediately - mCaptureButton.performClick(); - } - updateViewState(); - } - - private void showShutterEffect(final View shutterVisual) { - final float maxAlpha = getContext().getResources().getFraction( - R.fraction.camera_shutter_max_alpha, 1 /* base */, 1 /* pBase */); - - // Divide by 2 so each half of the animation adds up to the full duration - final int animationDuration = getContext().getResources().getInteger( - R.integer.camera_shutter_duration) / 2; - - final AnimationSet animation = new AnimationSet(false /* shareInterpolator */); - final Animation alphaInAnimation = new AlphaAnimation(0.0f, maxAlpha); - alphaInAnimation.setDuration(animationDuration); - animation.addAnimation(alphaInAnimation); - - final Animation alphaOutAnimation = new AlphaAnimation(maxAlpha, 0.0f); - alphaOutAnimation.setStartOffset(animationDuration); - alphaOutAnimation.setDuration(animationDuration); - animation.addAnimation(alphaOutAnimation); - - animation.setAnimationListener(new Animation.AnimationListener() { - @Override - public void onAnimationStart(final Animation animation) { - shutterVisual.setVisibility(View.VISIBLE); - } - - @Override - public void onAnimationEnd(final Animation animation) { - shutterVisual.setVisibility(View.GONE); - } - - @Override - public void onAnimationRepeat(final Animation animation) { - } - }); - shutterVisual.startAnimation(animation); - } - - /** Updates the state of the buttons and overlays based on the current state of the view */ - private void updateViewState() { - if (mView == null) { - return; - } - - final Context context = getContext(); - if (context == null) { - // Context is null if the fragment was already removed from the activity - return; - } - final boolean fullScreen = mMediaPicker.isFullScreen(); - final boolean videoMode = CameraManager.get().isVideoMode(); - final boolean isRecording = CameraManager.get().isRecording(); - final boolean isCameraAvailable = isCameraAvailable(); - final Camera.CameraInfo cameraInfo = CameraManager.get().getCameraInfo(); - final boolean frontCamera = cameraInfo != null && cameraInfo.facing == - Camera.CameraInfo.CAMERA_FACING_FRONT; - - mView.setSystemUiVisibility( - fullScreen ? View.SYSTEM_UI_FLAG_LOW_PROFILE : - View.SYSTEM_UI_FLAG_VISIBLE); - - mFullScreenButton.setVisibility(!fullScreen ? View.VISIBLE : View.GONE); - mFullScreenButton.setEnabled(isCameraAvailable); - mSwapCameraButton.setVisibility( - fullScreen && !isRecording && CameraManager.get().hasFrontAndBackCamera() ? - View.VISIBLE : View.GONE); - mSwapCameraButton.setImageResource(frontCamera ? - R.drawable.ic_camera_front_light : - R.drawable.ic_camera_rear_light); - mSwapCameraButton.setEnabled(isCameraAvailable); - - mCancelVideoButton.setVisibility(isRecording ? View.VISIBLE : View.GONE); - mVideoCounter.setVisibility(isRecording ? View.VISIBLE : View.GONE); - - mSwapModeButton.setImageResource(videoMode ? - R.drawable.ic_mp_camera_small_light : - R.drawable.ic_mp_video_small_light); - mSwapModeButton.setContentDescription(context.getString(videoMode ? - R.string.camera_switch_to_still_mode : R.string.camera_switch_to_video_mode)); - mSwapModeButton.setVisibility(isRecording ? View.GONE : View.VISIBLE); - mSwapModeButton.setEnabled(isCameraAvailable); - - if (isRecording) { - mCaptureButton.setImageResource(R.drawable.ic_mp_capture_stop_large_light); - mCaptureButton.setContentDescription(context.getString( - R.string.camera_stop_recording)); - } else if (videoMode) { - mCaptureButton.setImageResource(R.drawable.ic_mp_video_large_light); - mCaptureButton.setContentDescription(context.getString( - R.string.camera_start_recording)); - } else { - mCaptureButton.setImageResource(R.drawable.ic_checkmark_large_light); - mCaptureButton.setContentDescription(context.getString( - R.string.camera_take_picture)); - } - mCaptureButton.setEnabled(isCameraAvailable); - } - - @Override - int getActionBarTitleResId() { - return 0; - } - - /** - * Returns if the camera is currently ready camera is loaded and not taking a picture. - * otherwise we should avoid taking another picture, swapping camera or recording video. - */ - private boolean isCameraAvailable() { - return CameraManager.get().isCameraAvailable(); - } -} diff --git a/src/com/android/messaging/ui/mediapicker/CameraMediaChooserView.java b/src/com/android/messaging/ui/mediapicker/CameraMediaChooserView.java deleted file mode 100644 index b29b2bf8..00000000 --- a/src/com/android/messaging/ui/mediapicker/CameraMediaChooserView.java +++ /dev/null @@ -1,105 +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.mediapicker; - -import android.annotation.SuppressLint; -import android.content.Context; -import android.graphics.Canvas; -import android.hardware.Camera; -import android.os.Bundle; -import android.os.Parcelable; -import android.util.AttributeSet; -import android.view.ViewGroup; -import android.widget.FrameLayout; -import com.android.messaging.R; -import com.android.messaging.ui.PersistentInstanceState; -import com.android.messaging.util.ThreadUtil; - -public class CameraMediaChooserView extends FrameLayout implements PersistentInstanceState { - private static final String KEY_CAMERA_INDEX = "camera_index"; - - // True if we have at least queued an update to the view tree to support software rendering - // fallback - private boolean mIsSoftwareFallbackActive; - - public CameraMediaChooserView(final Context context, final AttributeSet attrs) { - super(context, attrs); - } - - @SuppressLint("MissingSuperCall") - @Override - protected Parcelable onSaveInstanceState() { - final Bundle bundle = new Bundle(); - bundle.putInt(KEY_CAMERA_INDEX, CameraManager.get().getCameraIndex()); - return bundle; - } - - @SuppressLint("MissingSuperCall") - @Override - protected void onRestoreInstanceState(final Parcelable state) { - if (!(state instanceof Bundle)) { - return; - } - - final Bundle bundle = (Bundle) state; - CameraManager.get().selectCameraByIndex(bundle.getInt(KEY_CAMERA_INDEX)); - } - - @Override - public Parcelable saveState() { - return onSaveInstanceState(); - } - - @Override - public void restoreState(final Parcelable restoredState) { - onRestoreInstanceState(restoredState); - } - - @Override - public void resetState() { - CameraManager.get().selectCamera(Camera.CameraInfo.CAMERA_FACING_BACK); - } - - @Override - protected void onDraw(final Canvas canvas) { - super.onDraw(canvas); - // If the canvas isn't hardware accelerated, we have to replace the HardwareCameraPreview - // with a SoftwareCameraPreview which supports software rendering - if (!canvas.isHardwareAccelerated() && !mIsSoftwareFallbackActive) { - mIsSoftwareFallbackActive = true; - // Post modifying the tree since we can't modify the view tree during a draw pass - ThreadUtil.getMainThreadHandler().post(new Runnable() { - @Override - public void run() { - final HardwareCameraPreview cameraPreview = - (HardwareCameraPreview) findViewById(R.id.camera_preview); - if (cameraPreview == null) { - return; - } - final ViewGroup parent = ((ViewGroup) cameraPreview.getParent()); - final int index = parent.indexOfChild(cameraPreview); - final SoftwareCameraPreview softwareCameraPreview = - new SoftwareCameraPreview(getContext()); - // Be sure to remove the hardware view before adding the software view to - // prevent having 2 camera previews active at the same time - parent.removeView(cameraPreview); - parent.addView(softwareCameraPreview, index); - } - }); - } - } -} diff --git a/src/com/android/messaging/ui/mediapicker/CameraPreview.java b/src/com/android/messaging/ui/mediapicker/CameraPreview.java deleted file mode 100644 index ecac978a..00000000 --- a/src/com/android/messaging/ui/mediapicker/CameraPreview.java +++ /dev/null @@ -1,152 +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.mediapicker; - -import android.content.Context; -import android.content.res.Configuration; -import android.hardware.Camera; -import android.view.View; -import android.view.View.MeasureSpec; -import com.android.messaging.util.Assert; - -import java.io.IOException; - -/** - * Contains shared code for SoftwareCameraPreview and HardwareCameraPreview, cannot use inheritance - * because those classes must inherit from separate Views, so those classes delegate calls to this - * helper class. Specifics for each implementation are in CameraPreviewHost - */ -public class CameraPreview { - public interface CameraPreviewHost { - View getView(); - boolean isValid(); - void startPreview(final Camera camera) throws IOException; - void onCameraPermissionGranted(); - - } - - private int mCameraWidth = -1; - private int mCameraHeight = -1; - - private final CameraPreviewHost mHost; - - public CameraPreview(final CameraPreviewHost host) { - Assert.notNull(host); - Assert.notNull(host.getView()); - mHost = host; - } - - public void setSize(final Camera.Size size, final int orientation) { - switch (orientation) { - case 0: - case 180: - mCameraWidth = size.width; - mCameraHeight = size.height; - break; - case 90: - case 270: - default: - mCameraWidth = size.height; - mCameraHeight = size.width; - } - mHost.getView().requestLayout(); - } - - public int getWidthMeasureSpec(final int widthMeasureSpec, final int heightMeasureSpec) { - if (mCameraHeight >= 0) { - final int width = View.MeasureSpec.getSize(widthMeasureSpec); - return MeasureSpec.makeMeasureSpec(width, View.MeasureSpec.EXACTLY); - } else { - return widthMeasureSpec; - } - } - - public int getHeightMeasureSpec(final int widthMeasureSpec, final int heightMeasureSpec) { - if (mCameraHeight >= 0) { - final int orientation = getContext().getResources().getConfiguration().orientation; - final int width = View.MeasureSpec.getSize(widthMeasureSpec); - final float aspectRatio = (float) mCameraWidth / (float) mCameraHeight; - int height; - if (orientation == Configuration.ORIENTATION_LANDSCAPE) { - height = (int) (width * aspectRatio); - } else { - height = (int) (width / aspectRatio); - } - return View.MeasureSpec.makeMeasureSpec(height, View.MeasureSpec.EXACTLY); - } else { - return heightMeasureSpec; - } - } - - public void onVisibilityChanged(final int visibility) { - if (CameraManager.hasCameraPermission()) { - if (visibility == View.VISIBLE) { - CameraManager.get().openCamera(); - } else { - CameraManager.get().closeCamera(); - } - } - } - - public Context getContext() { - return mHost.getView().getContext(); - } - - public void setOnTouchListener(final View.OnTouchListener listener) { - mHost.getView().setOnTouchListener(listener); - } - - public int getHeight() { - return mHost.getView().getHeight(); - } - - public void onAttachedToWindow() { - if (CameraManager.hasCameraPermission()) { - CameraManager.get().openCamera(); - } - } - - public void onDetachedFromWindow() { - CameraManager.get().closeCamera(); - } - - public void onRestoreInstanceState() { - if (CameraManager.hasCameraPermission()) { - CameraManager.get().openCamera(); - } - } - - public void onCameraPermissionGranted() { - CameraManager.get().openCamera(); - } - - /** - * @return True if the view is valid and prepared for the camera to start showing the preview - */ - public boolean isValid() { - return mHost.isValid(); - } - - /** - * Starts the camera preview on the current surface. Abstracts out the differences in API - * from the CameraManager - * @throws IOException Which is caught by the CameraManager to display an error - */ - public void startPreview(final Camera camera) throws IOException { - mHost.startPreview(camera); - } -} diff --git a/src/com/android/messaging/ui/mediapicker/ContactMediaChooser.java b/src/com/android/messaging/ui/mediapicker/ContactMediaChooser.java deleted file mode 100644 index a81ed080..00000000 --- a/src/com/android/messaging/ui/mediapicker/ContactMediaChooser.java +++ /dev/null @@ -1,144 +0,0 @@ -/* - * Copyright (C) 2020 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.mediapicker; - -import android.Manifest; -import android.app.Activity; -import android.content.Intent; -import android.content.pm.PackageManager; -import android.database.Cursor; -import android.net.Uri; -import android.provider.ContactsContract.Contacts; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; - -import com.android.messaging.R; -import com.android.messaging.datamodel.data.PendingAttachmentData; -import com.android.messaging.ui.UIIntents; -import com.android.messaging.util.ContactUtil; -import com.android.messaging.util.ContentType; -import com.android.messaging.util.SafeAsyncTask; - -/** - * Chooser which allows the user to select an existing contact from contacts apps on this device. - * Note that this chooser requires the Manifest.permission.READ_CONTACTS which is one of the miminum - * set of permissions for this app. Thus no case to request READ_CONTACTS permission on it actually. - */ -class ContactMediaChooser extends MediaChooser { - private View mEnabledView; - private View mMissingPermissionView; - - ContactMediaChooser(final MediaPicker mediaPicker) { - super(mediaPicker); - } - - @Override - public int getSupportedMediaTypes() { - return MediaPicker.MEDIA_TYPE_VCARD; - } - - @Override - public int getIconResource() { - return R.drawable.ic_person_light; - } - - @Override - public int getIconDescriptionResource() { - return R.string.mediapicker_contactChooserDescription; - } - - @Override - int getActionBarTitleResId() { - return R.string.mediapicker_contact_title; - } - - @Override - protected View createView(final ViewGroup container) { - final LayoutInflater inflater = getLayoutInflater(); - final View view = - inflater.inflate( - R.layout.mediapicker_contact_chooser, - container /* root */, - false /* attachToRoot */); - mEnabledView = view.findViewById(R.id.mediapicker_enabled); - mMissingPermissionView = view.findViewById(R.id.missing_permission_view); - mEnabledView.setOnClickListener( - new View.OnClickListener() { - @Override - public void onClick(final View v) { - // Launch an external picker to pick a contact as attachment. - UIIntents.get().launchContactCardPicker(mMediaPicker); - } - }); - return view; - } - - @Override - protected void setSelected(final boolean selected) { - super.setSelected(selected); - if (selected && !ContactUtil.hasReadContactsPermission()) { - mMediaPicker.requestPermissions( - new String[] {Manifest.permission.READ_CONTACTS}, - MediaPicker.READ_CONTACT_PERMISSION_REQUEST_CODE); - } - } - - @Override - protected void onRequestPermissionsResult( - final int requestCode, final String permissions[], final int[] grantResults) { - if (requestCode == MediaPicker.READ_CONTACT_PERMISSION_REQUEST_CODE) { - final boolean permissionGranted = grantResults[0] == PackageManager.PERMISSION_GRANTED; - mEnabledView.setVisibility(permissionGranted ? View.VISIBLE : View.GONE); - mMissingPermissionView.setVisibility(permissionGranted ? View.GONE : View.VISIBLE); - } - } - - @Override - protected void onActivityResult(int requestCode, int resultCode, Intent data) { - if (requestCode == UIIntents.REQUEST_PICK_CONTACT_CARD - && resultCode == Activity.RESULT_OK) { - Uri contactUri = data.getData(); - if (contactUri != null) { - String lookupKey = null; - try (final Cursor c = getContext().getContentResolver().query( - contactUri, - new String[] {Contacts.LOOKUP_KEY}, - null, - null, - null)) { - if (c != null) { - c.moveToFirst(); - lookupKey = c.getString(0); - } - } - final Uri vCardUri = Uri.withAppendedPath(Contacts.CONTENT_VCARD_URI, lookupKey); - if (vCardUri != null) { - SafeAsyncTask.executeOnThreadPool(new Runnable() { - @Override - public void run() { - final PendingAttachmentData pendingItem = - PendingAttachmentData.createPendingAttachmentData( - ContentType.TEXT_VCARD.toLowerCase(), vCardUri); - mMediaPicker.dispatchPendingItemAdded(pendingItem); - } - }); - } - } - } - } -} diff --git a/src/com/android/messaging/ui/mediapicker/DocumentImagePicker.java b/src/com/android/messaging/ui/mediapicker/DocumentImagePicker.java deleted file mode 100644 index e40722f5..00000000 --- a/src/com/android/messaging/ui/mediapicker/DocumentImagePicker.java +++ /dev/null @@ -1,138 +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.mediapicker; - -import android.content.Intent; -import android.net.Uri; -import android.os.Bundle; - -import androidx.fragment.app.Fragment; - -import com.android.messaging.Factory; -import com.android.messaging.datamodel.data.PendingAttachmentData; -import com.android.messaging.ui.UIIntents; -import com.android.messaging.util.LogUtil; -import com.android.messaging.util.FileUtil; -import com.android.messaging.util.ImageUtils; -import com.android.messaging.util.SafeAsyncTask; - -/** - * Wraps around the functionalities to allow the user to pick an image/video/audio from the document - * picker. Instances of this class must be tied to a Fragment which is able to delegate activity - * result callbacks. - */ -public class DocumentImagePicker { - - /** - * An interface for a listener that listens for when a document has been picked. - */ - public interface SelectionListener { - /** - * Called when an document is selected from picker. At this point, the file hasn't been - * actually loaded and staged in the temp directory, so we are passing in a pending - * MessagePartData, which the consumer should use to display a placeholder image. - * @param pendingItem a temporary attachment data for showing the placeholder state. - */ - void onDocumentSelected(PendingAttachmentData pendingItem); - } - - // The owning fragment. - private final Fragment mFragment; - - // The listener on the picker events. - private final SelectionListener mListener; - - private static final String EXTRA_PHOTO_URL = "photo_url"; - - /** - * Creates a new instance of DocumentImagePicker. - * @param activity The activity that owns the picker, or the activity that hosts the owning - * fragment. - */ - public DocumentImagePicker(final Fragment fragment, - final SelectionListener listener) { - mFragment = fragment; - mListener = listener; - } - - /** - * Intent out to open an image/video from document picker. - */ - public void launchPicker() { - UIIntents.get().launchDocumentImagePicker(mFragment); - } - - /** - * Must be called from the fragment/activity's onActivityResult(). - */ - public void onActivityResult(final int requestCode, final int resultCode, final Intent data) { - // Sometimes called after media item has been picked from the document picker. - String url = data.getStringExtra(EXTRA_PHOTO_URL); - if (url == null) { - // we're using the builtin photo picker which supplies the return - // url as it's "data" - url = data.getDataString(); - if (url == null) { - final Bundle extras = data.getExtras(); - if (extras != null) { - final Uri uri = (Uri) extras.getParcelable(Intent.EXTRA_STREAM); - if (uri != null) { - url = uri.toString(); - } - } - } - } - - // Guard against null uri cases for when the activity returns a null/invalid intent. - if (url != null) { - final Uri uri = Uri.parse(url); - prepareDocumentForAttachment(uri); - } - } - - private void prepareDocumentForAttachment(final Uri documentUri) { - // Notify our listener with a PendingAttachmentData containing the metadata. - // Asynchronously get the content type for the picked image since - // ImageUtils.getContentType() potentially involves I/O and can be expensive. - new SafeAsyncTask() { - @Override - protected String doInBackgroundTimed(final Void... params) { - if (FileUtil.isInPrivateDir(documentUri)) { - // hacker sending private app data. Bail out - if (LogUtil.isLoggable(LogUtil.BUGLE_TAG, LogUtil.ERROR)) { - LogUtil.e(LogUtil.BUGLE_TAG, "Aborting attach of private app data (" - + documentUri + ")"); - } - return null; - } - return ImageUtils.getContentType( - Factory.get().getApplicationContext().getContentResolver(), documentUri); - } - - @Override - protected void onPostExecute(final String contentType) { - if (contentType == null) { - return; // bad uri on input - } - // Ask the listener to create a temporary placeholder item to show the progress. - final PendingAttachmentData pendingItem = - PendingAttachmentData.createPendingAttachmentData(contentType, - documentUri); - mListener.onDocumentSelected(pendingItem); - } - }.executeOnThreadPool(); - } -} diff --git a/src/com/android/messaging/ui/mediapicker/GalleryGridAdapter.java b/src/com/android/messaging/ui/mediapicker/GalleryGridAdapter.java deleted file mode 100644 index fda3b190..00000000 --- a/src/com/android/messaging/ui/mediapicker/GalleryGridAdapter.java +++ /dev/null @@ -1,62 +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.mediapicker; - -import android.content.Context; -import android.database.Cursor; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.widget.CursorAdapter; - -import com.android.messaging.R; -import com.android.messaging.ui.mediapicker.GalleryGridItemView.HostInterface; -import com.android.messaging.util.Assert; - -/** - * Bridges between the image cursor loaded by GalleryBoundCursorLoader and the GalleryGridView. - */ -public class GalleryGridAdapter extends CursorAdapter { - private GalleryGridItemView.HostInterface mGgivHostInterface; - - public GalleryGridAdapter(final Context context, final Cursor cursor) { - super(context, cursor, 0); - } - - public void setHostInterface(final HostInterface ggivHostInterface) { - mGgivHostInterface = ggivHostInterface; - } - - /** - * {@inheritDoc} - */ - @Override - public void bindView(final View view, final Context context, final Cursor cursor) { - Assert.isTrue(view instanceof GalleryGridItemView); - final GalleryGridItemView galleryImageView = (GalleryGridItemView) view; - galleryImageView.bind(cursor, mGgivHostInterface); - } - - /** - * {@inheritDoc} - */ - @Override - public View newView(final Context context, final Cursor cursor, final ViewGroup parent) { - final LayoutInflater layoutInflater = LayoutInflater.from(context); - return layoutInflater.inflate(R.layout.gallery_grid_item_view, parent, false); - } -} diff --git a/src/com/android/messaging/ui/mediapicker/GalleryGridItemView.java b/src/com/android/messaging/ui/mediapicker/GalleryGridItemView.java deleted file mode 100644 index 6cf509b9..00000000 --- a/src/com/android/messaging/ui/mediapicker/GalleryGridItemView.java +++ /dev/null @@ -1,203 +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.mediapicker; - -import android.content.Context; -import android.database.Cursor; -import android.graphics.PorterDuff; -import android.graphics.Rect; -import android.util.AttributeSet; -import android.view.MotionEvent; -import android.view.TouchDelegate; -import android.view.View; -import android.widget.CheckBox; -import android.widget.FrameLayout; -import android.widget.ImageView; -import android.widget.LinearLayout; -import android.widget.RelativeLayout; -import android.widget.TextView; - -import com.android.messaging.R; -import com.android.messaging.datamodel.DataModel; -import com.android.messaging.datamodel.data.GalleryGridItemData; -import com.android.messaging.ui.AsyncImageView; -import com.android.messaging.ui.ConversationDrawables; -import com.android.messaging.util.ContentType; -import com.google.common.annotations.VisibleForTesting; - -import java.util.concurrent.TimeUnit; - -/** - * Shows an item in the gallery picker grid view. Hosts an FileImageView with a checkbox. - */ -public class GalleryGridItemView extends FrameLayout { - /** - * Implemented by the owner of this GalleryGridItemView instance to communicate on media - * picking and selection events. - */ - public interface HostInterface { - void onItemClicked(View view, GalleryGridItemData data, boolean longClick); - boolean isItemSelected(GalleryGridItemData data); - boolean isMultiSelectEnabled(); - } - - @VisibleForTesting - GalleryGridItemData mData; - private AsyncImageView mImageView; - private CheckBox mCheckBox; - private RelativeLayout mAdditionalInfo; - private ImageView mIcon; - private LinearLayout mFileInfo; - private TextView mFileName; - private TextView mFileType; - private HostInterface mHostInterface; - private final OnClickListener mOnClickListener = new OnClickListener() { - @Override - public void onClick(final View v) { - mHostInterface.onItemClicked(GalleryGridItemView.this, mData, false /*longClick*/); - } - }; - - public GalleryGridItemView(final Context context, final AttributeSet attrs) { - super(context, attrs); - mData = DataModel.get().createGalleryGridItemData(); - } - - @Override - protected void onFinishInflate() { - super.onFinishInflate(); - mImageView = (AsyncImageView) findViewById(R.id.thumbnail); - mCheckBox = (CheckBox) findViewById(R.id.checkbox); - mCheckBox.setOnClickListener(mOnClickListener); - mAdditionalInfo = (RelativeLayout) findViewById(R.id.additional_info); - mIcon = (ImageView) findViewById(R.id.icon); - mFileInfo = (LinearLayout) findViewById(R.id.file_info); - mFileName = (TextView) findViewById(R.id.file_name); - mFileType = (TextView) findViewById(R.id.file_type); - setOnClickListener(mOnClickListener); - final OnLongClickListener longClickListener = new OnLongClickListener() { - @Override - public boolean onLongClick(final View v) { - mHostInterface.onItemClicked(v, mData, true /* longClick */); - return true; - } - }; - setOnLongClickListener(longClickListener); - mCheckBox.setOnLongClickListener(longClickListener); - addOnLayoutChangeListener(new OnLayoutChangeListener() { - @Override - public void onLayoutChange(View v, int left, int top, int right, int bottom, - int oldLeft, int oldTop, int oldRight, int oldBottom) { - // Enlarge the clickable region for the checkbox to fill the entire view. - final Rect region = new Rect(0, 0, getWidth(), getHeight()); - setTouchDelegate(new TouchDelegate(region, mCheckBox) { - @Override - public boolean onTouchEvent(MotionEvent event) { - switch (event.getAction()) { - case MotionEvent.ACTION_DOWN: - setPressed(true); - break; - case MotionEvent.ACTION_UP: - case MotionEvent.ACTION_CANCEL: - setPressed(false); - break; - } - return super.onTouchEvent(event); - } - }); - } - }); - } - - @Override - protected void onMeasure(final int widthMeasureSpec, final int heightMeasureSpec) { - // The grid view auto-fit the columns, so we want to let the height match the width - // to make the image square. - super.onMeasure(widthMeasureSpec, widthMeasureSpec); - } - - public void bind(final Cursor cursor, final HostInterface hostInterface) { - final int desiredSize = getResources() - .getDimensionPixelSize(R.dimen.gallery_image_cell_size); - mData.bind(cursor, desiredSize, desiredSize); - mHostInterface = hostInterface; - updateViewState(); - } - - private void updateViewState() { - updateImageView(); - if (mHostInterface.isMultiSelectEnabled() && !mData.isDocumentPickerItem()) { - mCheckBox.setVisibility(VISIBLE); - mCheckBox.setClickable(true); - mCheckBox.setChecked(mHostInterface.isItemSelected(mData)); - } else { - mCheckBox.setVisibility(GONE); - mCheckBox.setClickable(false); - } - } - - private void updateImageView() { - if (mData.isDocumentPickerItem()) { - setBackgroundColor(ConversationDrawables.get().getConversationThemeColor()); - mIcon.setImageResource(R.drawable.ic_photo_library_light); - mIcon.clearColorFilter(); - mImageView.setVisibility(GONE); - mIcon.setVisibility(VISIBLE); - mFileInfo.setVisibility(GONE); - mAdditionalInfo.setVisibility(VISIBLE); - } else { - final String contentType = mData.getContentType(); - if (ContentType.isAudioType(contentType)) { - setBackgroundColor( - getResources().getColor(R.color.gallery_image_default_background)); - mIcon.setImageResource(R.drawable.ic_music); - mIcon.setColorFilter( - ConversationDrawables.get().getConversationThemeColor(), - PorterDuff.Mode.SRC_IN); - mFileName.setText(mData.getFileName()); - String[] type = contentType.split("/"); - mFileType.setText(type[1].toUpperCase() + " " + type[0]); - mImageView.setVisibility(GONE); - mIcon.setVisibility(VISIBLE); - mFileInfo.setVisibility(VISIBLE); - mAdditionalInfo.setVisibility(VISIBLE); - } else { // For image and video types - mImageView.setScaleType(ImageView.ScaleType.CENTER_CROP); - setBackgroundColor( - getResources().getColor(R.color.gallery_image_default_background)); - mImageView.setImageResourceId(mData.getImageRequestDescriptor()); - mImageView.setVisibility(VISIBLE); - if (ContentType.isVideoType(mData.getContentType())) { - mIcon.setImageResource(R.drawable.ic_video_play_light); - mIcon.clearColorFilter(); - mIcon.setVisibility(VISIBLE); - } else { - mIcon.setVisibility(GONE); - } - mFileInfo.setVisibility(GONE); - mAdditionalInfo.setVisibility(VISIBLE); - final long dateSeconds = mData.getDateSeconds(); - final boolean isValidDate = (dateSeconds > 0); - final int templateId = isValidDate ? - R.string.mediapicker_gallery_image_item_description : - R.string.mediapicker_gallery_image_item_description_no_date; - String contentDescription = String.format(getResources().getString(templateId), - dateSeconds * TimeUnit.SECONDS.toMillis(1)); - mImageView.setContentDescription(contentDescription); - } - } - } -} diff --git a/src/com/android/messaging/ui/mediapicker/GalleryGridView.java b/src/com/android/messaging/ui/mediapicker/GalleryGridView.java deleted file mode 100644 index 2ee0fd06..00000000 --- a/src/com/android/messaging/ui/mediapicker/GalleryGridView.java +++ /dev/null @@ -1,316 +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.mediapicker; - -import android.content.Context; -import android.graphics.Rect; -import android.net.Uri; -import android.os.Parcel; -import android.os.Parcelable; -import android.util.AttributeSet; -import android.view.Menu; -import android.view.MenuInflater; -import android.view.MenuItem; -import android.view.View; - -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.DraftMessageData; -import com.android.messaging.datamodel.data.DraftMessageData.DraftMessageDataListener; -import com.android.messaging.datamodel.data.GalleryGridItemData; -import com.android.messaging.datamodel.data.MessagePartData; -import com.android.messaging.ui.PersistentInstanceState; -import com.android.messaging.util.Assert; -import com.android.messaging.util.ContentType; -import com.android.messaging.util.LogUtil; - -import java.util.Iterator; -import java.util.Map; - -import androidx.collection.ArrayMap; - -/** - * Shows a list of galley mediae from external storage in a GridView with multi-select capabilities, - * and with the option to intent out to a standalone media picker. - */ -public class GalleryGridView extends MediaPickerGridView implements - GalleryGridItemView.HostInterface, - PersistentInstanceState, - DraftMessageDataListener { - /** - * Implemented by the owner of this GalleryGridView instance to communicate on media picking and - * multi-media selection events. - */ - public interface GalleryGridViewListener { - void onDocumentPickerItemClicked(); - void onItemSelected(MessagePartData item); - void onItemUnselected(MessagePartData item); - void onConfirmSelection(); - void onUpdate(); - } - - private GalleryGridViewListener mListener; - - // TODO: Consider putting this into the data model object if we add more states. - private final ArrayMap mSelectedImages; - private boolean mIsMultiSelectMode = false; - private ImmutableBindingRef mDraftMessageDataModel; - - public GalleryGridView(final Context context, final AttributeSet attrs) { - super(context, attrs); - mSelectedImages = new ArrayMap(); - } - - public void setHostInterface(final GalleryGridViewListener hostInterface) { - mListener = hostInterface; - } - - public void setDraftMessageDataModel(final BindingBase dataModel) { - mDraftMessageDataModel = BindingBase.createBindingReference(dataModel); - mDraftMessageDataModel.getData().addListener(this); - } - - @Override - public void onItemClicked(final View view, final GalleryGridItemData data, - final boolean longClick) { - if (data.isDocumentPickerItem()) { - mListener.onDocumentPickerItemClicked(); - } else if (ContentType.isMediaType(data.getContentType())) { - if (longClick) { - // Turn on multi-select mode when an item is long-pressed. - setMultiSelectEnabled(true); - } - - final Rect startRect = new Rect(); - view.getGlobalVisibleRect(startRect); - if (isMultiSelectEnabled()) { - toggleItemSelection(startRect, data); - } else { - mListener.onItemSelected(data.constructMessagePartData(startRect)); - } - } else { - LogUtil.w(LogUtil.BUGLE_TAG, - "Selected item has invalid contentType " + data.getContentType()); - } - } - - @Override - public boolean isItemSelected(final GalleryGridItemData data) { - return mSelectedImages.containsKey(data.getImageUri()); - } - - int getSelectionCount() { - return mSelectedImages.size(); - } - - @Override - public boolean isMultiSelectEnabled() { - return mIsMultiSelectMode; - } - - private void toggleItemSelection(final Rect startRect, final GalleryGridItemData data) { - Assert.isTrue(isMultiSelectEnabled()); - if (isItemSelected(data)) { - final MessagePartData item = mSelectedImages.remove(data.getImageUri()); - mListener.onItemUnselected(item); - if (mSelectedImages.size() == 0) { - // No media is selected any more, turn off multi-select mode. - setMultiSelectEnabled(false); - } - } else { - final MessagePartData item = data.constructMessagePartData(startRect); - mSelectedImages.put(data.getImageUri(), item); - mListener.onItemSelected(item); - } - invalidateViews(); - } - - private void toggleMultiSelect() { - mIsMultiSelectMode = !mIsMultiSelectMode; - invalidateViews(); - } - - private void setMultiSelectEnabled(final boolean enabled) { - if (mIsMultiSelectMode != enabled) { - toggleMultiSelect(); - } - } - - private boolean canToggleMultiSelect() { - // We allow the user to toggle multi-select mode only when nothing has selected. If - // something has been selected, we show a confirm button instead. - return mSelectedImages.size() == 0; - } - - public void onCreateOptionsMenu(final MenuInflater inflater, final Menu menu) { - inflater.inflate(R.menu.gallery_picker_menu, menu); - final MenuItem toggleMultiSelect = menu.findItem(R.id.action_multiselect); - final MenuItem confirmMultiSelect = menu.findItem(R.id.action_confirm_multiselect); - final boolean canToggleMultiSelect = canToggleMultiSelect(); - toggleMultiSelect.setVisible(canToggleMultiSelect); - confirmMultiSelect.setVisible(!canToggleMultiSelect); - } - - public boolean onOptionsItemSelected(final MenuItem item) { - int itemId = item.getItemId(); - if (itemId == R.id.action_multiselect) { - Assert.isTrue(canToggleMultiSelect()); - toggleMultiSelect(); - return true; - } - if (itemId == R.id.action_confirm_multiselect) { - Assert.isTrue(! canToggleMultiSelect()); - mListener.onConfirmSelection(); - return true; - } - return false; - } - - - @Override - public void onDraftChanged(final DraftMessageData data, final int changeFlags) { - mDraftMessageDataModel.ensureBound(data); - // Whenever attachment changed, refresh selection state to remove those that are not - // selected. - if ((changeFlags & DraftMessageData.ATTACHMENTS_CHANGED) == - DraftMessageData.ATTACHMENTS_CHANGED) { - refreshImageSelectionStateOnAttachmentChange(); - } - } - - @Override - public void onDraftAttachmentLimitReached(final DraftMessageData data) { - mDraftMessageDataModel.ensureBound(data); - // Whenever draft attachment limit is reach, refresh selection state to remove those - // not actually added to draft. - refreshImageSelectionStateOnAttachmentChange(); - } - - @Override - public void onDraftAttachmentLoadFailed() { - // Nothing to do since the failed attachment gets removed automatically. - } - - private void refreshImageSelectionStateOnAttachmentChange() { - boolean changed = false; - final Iterator> iterator = - mSelectedImages.entrySet().iterator(); - while (iterator.hasNext()) { - Map.Entry entry = iterator.next(); - if (!mDraftMessageDataModel.getData().containsAttachment(entry.getKey())) { - iterator.remove(); - changed = true; - } - } - - if (changed) { - mListener.onUpdate(); - invalidateViews(); - } - } - - @Override // PersistentInstanceState - public Parcelable saveState() { - return onSaveInstanceState(); - } - - @Override // PersistentInstanceState - public void restoreState(final Parcelable restoredState) { - onRestoreInstanceState(restoredState); - invalidateViews(); - } - - @Override - public Parcelable onSaveInstanceState() { - final Parcelable superState = super.onSaveInstanceState(); - final SavedState savedState = new SavedState(superState); - savedState.isMultiSelectMode = mIsMultiSelectMode; - savedState.selectedImages = mSelectedImages.values() - .toArray(new MessagePartData[mSelectedImages.size()]); - return savedState; - } - - @Override - public void onRestoreInstanceState(final Parcelable state) { - if (!(state instanceof SavedState)) { - super.onRestoreInstanceState(state); - return; - } - - final SavedState savedState = (SavedState) state; - super.onRestoreInstanceState(savedState.getSuperState()); - mIsMultiSelectMode = savedState.isMultiSelectMode; - mSelectedImages.clear(); - for (int i = 0; i < savedState.selectedImages.length; i++) { - final MessagePartData selectedImage = savedState.selectedImages[i]; - mSelectedImages.put(selectedImage.getContentUri(), selectedImage); - } - } - - @Override // PersistentInstanceState - public void resetState() { - mSelectedImages.clear(); - mIsMultiSelectMode = false; - invalidateViews(); - } - - public static class SavedState extends BaseSavedState { - boolean isMultiSelectMode; - MessagePartData[] selectedImages; - - SavedState(final Parcelable superState) { - super(superState); - } - - private SavedState(final Parcel in) { - super(in); - isMultiSelectMode = in.readInt() == 1 ? true : false; - - // Read parts - final int partCount = in.readInt(); - selectedImages = new MessagePartData[partCount]; - for (int i = 0; i < partCount; i++) { - selectedImages[i] = ((MessagePartData) in.readParcelable( - MessagePartData.class.getClassLoader())); - } - } - - @Override - public void writeToParcel(final Parcel out, final int flags) { - super.writeToParcel(out, flags); - out.writeInt(isMultiSelectMode ? 1 : 0); - - // Write parts - out.writeInt(selectedImages.length); - for (final MessagePartData image : selectedImages) { - out.writeParcelable(image, flags); - } - } - - public static final Parcelable.Creator CREATOR = - new Parcelable.Creator() { - @Override - public SavedState createFromParcel(final Parcel in) { - return new SavedState(in); - } - @Override - public SavedState[] newArray(final int size) { - return new SavedState[size]; - } - }; - } -} diff --git a/src/com/android/messaging/ui/mediapicker/GalleryMediaChooser.java b/src/com/android/messaging/ui/mediapicker/GalleryMediaChooser.java deleted file mode 100644 index c5d14a52..00000000 --- a/src/com/android/messaging/ui/mediapicker/GalleryMediaChooser.java +++ /dev/null @@ -1,264 +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.mediapicker; - -import android.Manifest; -import android.app.Activity; -import android.content.Intent; -import android.content.pm.PackageManager; -import android.database.Cursor; -import android.database.MatrixCursor; -import android.database.MergeCursor; -import androidx.appcompat.app.ActionBar; -import android.view.LayoutInflater; -import android.view.Menu; -import android.view.MenuInflater; -import android.view.MenuItem; -import android.view.View; -import android.view.ViewGroup; - -import com.android.messaging.Factory; -import com.android.messaging.R; -import com.android.messaging.datamodel.data.GalleryGridItemData; -import com.android.messaging.datamodel.data.MediaPickerData; -import com.android.messaging.datamodel.data.MessagePartData; -import com.android.messaging.datamodel.data.MediaPickerData.MediaPickerDataListener; -import com.android.messaging.datamodel.data.PendingAttachmentData; -import com.android.messaging.ui.UIIntents; -import com.android.messaging.ui.mediapicker.DocumentImagePicker.SelectionListener; -import com.android.messaging.util.Assert; -import com.android.messaging.util.OsUtil; - -/** - * Chooser which allows the user to select one or more existing images or videos or audios. - */ -class GalleryMediaChooser extends MediaChooser implements - GalleryGridView.GalleryGridViewListener, MediaPickerDataListener { - private final GalleryGridAdapter mAdapter; - private GalleryGridView mGalleryGridView; - private View mMissingPermissionView; - - /** Handles picking a media from the document picker. */ - private DocumentImagePicker mDocumentImagePicker; - - GalleryMediaChooser(final MediaPicker mediaPicker) { - super(mediaPicker); - mAdapter = new GalleryGridAdapter(Factory.get().getApplicationContext(), null); - mDocumentImagePicker = new DocumentImagePicker(mMediaPicker, - new SelectionListener() { - @Override - public void onDocumentSelected(final PendingAttachmentData data) { - if (mBindingRef.isBound()) { - mMediaPicker.dispatchPendingItemAdded(data); - } - } - }); - } - - @Override - public int getSupportedMediaTypes() { - return (MediaPicker.MEDIA_TYPE_IMAGE - | MediaPicker.MEDIA_TYPE_VIDEO - | MediaPicker.MEDIA_TYPE_AUDIO); - } - - @Override - public View destroyView() { - mGalleryGridView.setAdapter(null); - mAdapter.setHostInterface(null); - // The loader is started only if startMediaPickerDataLoader() is called - if (hasStoragePermissions()) { - mBindingRef.getData().destroyLoader(MediaPickerData.GALLERY_MEDIA_LOADER); - } - return super.destroyView(); - } - - @Override - public int getIconResource() { - return R.drawable.ic_image_light; - } - - @Override - public int getIconDescriptionResource() { - return R.string.mediapicker_galleryChooserDescription; - } - - @Override - public boolean canSwipeDown() { - return mGalleryGridView.canSwipeDown(); - } - - @Override - public void onItemSelected(final MessagePartData item) { - mMediaPicker.dispatchItemsSelected(item, !mGalleryGridView.isMultiSelectEnabled()); - } - - @Override - public void onItemUnselected(final MessagePartData item) { - mMediaPicker.dispatchItemUnselected(item); - } - - @Override - public void onConfirmSelection() { - // The user may only confirm if multiselect is enabled. - Assert.isTrue(mGalleryGridView.isMultiSelectEnabled()); - mMediaPicker.dispatchConfirmItemSelection(); - } - - @Override - public void onUpdate() { - mMediaPicker.invalidateOptionsMenu(); - } - - @Override - public void onCreateOptionsMenu(final MenuInflater inflater, final Menu menu) { - if (mView != null) { - mGalleryGridView.onCreateOptionsMenu(inflater, menu); - } - } - - @Override - public boolean onOptionsItemSelected(final MenuItem item) { - return (mView != null) ? mGalleryGridView.onOptionsItemSelected(item) : false; - } - - @Override - protected View createView(final ViewGroup container) { - final LayoutInflater inflater = getLayoutInflater(); - final View view = inflater.inflate( - R.layout.mediapicker_gallery_chooser, - container /* root */, - false /* attachToRoot */); - - mGalleryGridView = (GalleryGridView) view.findViewById(R.id.gallery_grid_view); - mAdapter.setHostInterface(mGalleryGridView); - mGalleryGridView.setAdapter(mAdapter); - mGalleryGridView.setHostInterface(this); - mGalleryGridView.setDraftMessageDataModel(mMediaPicker.getDraftMessageDataModel()); - if (hasStoragePermissions()) { - startMediaPickerDataLoader(); - } - - mMissingPermissionView = view.findViewById(R.id.missing_permission_view); - updateForPermissionState(hasStoragePermissions()); - return view; - } - - @Override - int getActionBarTitleResId() { - return R.string.mediapicker_gallery_title; - } - - @Override - public void onDocumentPickerItemClicked() { - // Launch an external picker to pick item from document picker as attachment. - mDocumentImagePicker.launchPicker(); - } - - @Override - void updateActionBar(final ActionBar actionBar) { - super.updateActionBar(actionBar); - if (mGalleryGridView == null) { - return; - } - final int selectionCount = mGalleryGridView.getSelectionCount(); - if (selectionCount > 0 && mGalleryGridView.isMultiSelectEnabled()) { - actionBar.setTitle(getContext().getResources().getString( - R.string.mediapicker_gallery_title_selection, - selectionCount)); - } - } - - @Override - public void onMediaPickerDataUpdated(final MediaPickerData mediaPickerData, final Object data, - final int loaderId) { - mBindingRef.ensureBound(mediaPickerData); - Assert.equals(MediaPickerData.GALLERY_MEDIA_LOADER, loaderId); - Cursor rawCursor = null; - if (data instanceof Cursor) { - rawCursor = (Cursor) data; - } - // Before delivering the cursor, wrap around the local gallery cursor - // with an extra item for document picker integration in the front. - final MatrixCursor specialItemsCursor = - new MatrixCursor(GalleryGridItemData.SPECIAL_ITEM_COLUMNS); - specialItemsCursor.addRow(new Object[] { GalleryGridItemData.ID_DOCUMENT_PICKER_ITEM }); - final MergeCursor cursor = - new MergeCursor(new Cursor[] { specialItemsCursor, rawCursor }); - mAdapter.swapCursor(cursor); - } - - @Override - public void onResume() { - if (hasStoragePermissions()) { - // Work around a bug in MediaStore where cursors querying the Files provider don't get - // updated for changes to Images.Media or Video.Media. - startMediaPickerDataLoader(); - } - } - - @Override - protected void setSelected(final boolean selected) { - super.setSelected(selected); - if (selected && !hasStoragePermissions()) { - mMediaPicker.requestPermissions( - new String[] { Manifest.permission.READ_MEDIA_IMAGES, Manifest.permission.READ_MEDIA_VIDEO, Manifest.permission.READ_MEDIA_AUDIO }, - MediaPicker.GALLERY_PERMISSION_REQUEST_CODE); - } - } - - private void startMediaPickerDataLoader() { - mBindingRef - .getData() - .startLoader(MediaPickerData.GALLERY_MEDIA_LOADER, mBindingRef, null, this); - } - - @Override - protected void onRequestPermissionsResult( - final int requestCode, final String permissions[], final int[] grantResults) { - if (requestCode == MediaPicker.GALLERY_PERMISSION_REQUEST_CODE) { - final boolean permissionGranted = grantResults[0] == PackageManager.PERMISSION_GRANTED; - if (permissionGranted) { - startMediaPickerDataLoader(); - } - updateForPermissionState(permissionGranted); - } - } - - private void updateForPermissionState(final boolean granted) { - // onRequestPermissionsResult can sometimes get called before createView(). - if (mGalleryGridView == null) { - return; - } - - mGalleryGridView.setVisibility(granted ? View.VISIBLE : View.GONE); - mMissingPermissionView.setVisibility(granted ? View.GONE : View.VISIBLE); - } - - @Override - protected void onActivityResult( - final int requestCode, final int resultCode, final Intent data) { - if (requestCode == UIIntents.REQUEST_PICK_MEDIA_FROM_DOCUMENT_PICKER - && resultCode == Activity.RESULT_OK) { - mDocumentImagePicker.onActivityResult(requestCode, resultCode, data); - } - } - - private boolean hasStoragePermissions() { - return OsUtil.hasReadImagesPermission() || OsUtil.hasReadVideoPermission() || OsUtil.hasReadAudioPermission(); - } -} diff --git a/src/com/android/messaging/ui/mediapicker/HardwareCameraPreview.java b/src/com/android/messaging/ui/mediapicker/HardwareCameraPreview.java deleted file mode 100644 index 45d9579c..00000000 --- a/src/com/android/messaging/ui/mediapicker/HardwareCameraPreview.java +++ /dev/null @@ -1,118 +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.mediapicker; - -import android.content.Context; -import android.graphics.SurfaceTexture; -import android.hardware.Camera; -import android.os.Parcelable; -import android.util.AttributeSet; -import android.view.TextureView; -import android.view.View; - -import java.io.IOException; - -/** - * A hardware accelerated preview texture for the camera. This is the preferred CameraPreview - * because it animates smoother. When hardware acceleration isn't available, SoftwareCameraPreview - * is used. - * - * There is a significant amount of duplication between HardwareCameraPreview and - * SoftwareCameraPreview which we can't easily share due to a lack of multiple inheritance, The - * implementations of the shared methods are delegated to CameraPreview - */ -public class HardwareCameraPreview extends TextureView implements CameraPreview.CameraPreviewHost { - private CameraPreview mPreview; - - public HardwareCameraPreview(final Context context, final AttributeSet attrs) { - super(context, attrs); - mPreview = new CameraPreview(this); - setSurfaceTextureListener(new SurfaceTextureListener() { - @Override - public void onSurfaceTextureAvailable(final SurfaceTexture surfaceTexture, final int i, final int i2) { - CameraManager.get().setSurface(mPreview); - } - - @Override - public void onSurfaceTextureSizeChanged(final SurfaceTexture surfaceTexture, final int i, final int i2) { - CameraManager.get().setSurface(mPreview); - } - - @Override - public boolean onSurfaceTextureDestroyed(final SurfaceTexture surfaceTexture) { - CameraManager.get().setSurface(null); - return true; - } - - @Override - public void onSurfaceTextureUpdated(final SurfaceTexture surfaceTexture) { - CameraManager.get().setSurface(mPreview); - } - }); - } - - @Override - protected void onVisibilityChanged(final View changedView, final int visibility) { - super.onVisibilityChanged(changedView, visibility); - mPreview.onVisibilityChanged(visibility); - } - - @Override - protected void onDetachedFromWindow() { - super.onDetachedFromWindow(); - mPreview.onDetachedFromWindow(); - } - - @Override - protected void onAttachedToWindow() { - super.onAttachedToWindow(); - mPreview.onAttachedToWindow(); - } - - @Override - protected void onRestoreInstanceState(final Parcelable state) { - super.onRestoreInstanceState(state); - mPreview.onRestoreInstanceState(); - } - - @Override - protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { - widthMeasureSpec = mPreview.getWidthMeasureSpec(widthMeasureSpec, heightMeasureSpec); - heightMeasureSpec = mPreview.getHeightMeasureSpec(widthMeasureSpec, heightMeasureSpec); - super.onMeasure(widthMeasureSpec, heightMeasureSpec); - } - - @Override - public View getView() { - return this; - } - - @Override - public boolean isValid() { - return getSurfaceTexture() != null; - } - - @Override - public void startPreview(final Camera camera) throws IOException { - camera.setPreviewTexture(getSurfaceTexture()); - } - - @Override - public void onCameraPermissionGranted() { - mPreview.onCameraPermissionGranted(); - } -} diff --git a/src/com/android/messaging/ui/mediapicker/ImagePersistTask.java b/src/com/android/messaging/ui/mediapicker/ImagePersistTask.java deleted file mode 100644 index 637eb845..00000000 --- a/src/com/android/messaging/ui/mediapicker/ImagePersistTask.java +++ /dev/null @@ -1,172 +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.mediapicker; - -import android.content.Context; -import android.graphics.Bitmap; -import android.graphics.BitmapFactory; -import android.graphics.Canvas; -import android.graphics.Matrix; -import android.net.Uri; - -import com.android.messaging.datamodel.MediaScratchFileProvider; -import com.android.messaging.util.Assert; -import com.android.messaging.util.ContentType; -import com.android.messaging.util.LogUtil; -import com.android.messaging.util.SafeAsyncTask; -import com.android.messaging.util.exif.ExifInterface; -import com.android.messaging.util.exif.ExifTag; - -import java.io.IOException; -import java.io.OutputStream; - -public class ImagePersistTask extends SafeAsyncTask { - private static final String JPEG_EXTENSION = "jpg"; - private static final String TAG = LogUtil.BUGLE_TAG; - - private int mWidth; - private int mHeight; - private final float mHeightPercent; - private final byte[] mBytes; - private final Context mContext; - private final CameraManager.MediaCallback mCallback; - private Uri mOutputUri; - private Exception mException; - - public ImagePersistTask( - final int width, - final int height, - final float heightPercent, - final byte[] bytes, - final Context context, - final CameraManager.MediaCallback callback) { - Assert.isTrue(heightPercent >= 0 && heightPercent <= 1); - Assert.notNull(bytes); - Assert.notNull(context); - Assert.notNull(callback); - mWidth = width; - mHeight = height; - mHeightPercent = heightPercent; - mBytes = bytes; - mContext = context; - mCallback = callback; - // TODO: We probably want to store directly in MMS storage to prevent this - // intermediate step - mOutputUri = MediaScratchFileProvider.buildMediaScratchSpaceUri(JPEG_EXTENSION); - } - - @Override - protected Void doInBackgroundTimed(final Void... params) { - OutputStream outputStream = null; - Bitmap bitmap = null; - Bitmap clippedBitmap = null; - try { - outputStream = - mContext.getContentResolver().openOutputStream(mOutputUri); - if (mHeightPercent != 1.0f) { - int orientation = android.media.ExifInterface.ORIENTATION_UNDEFINED; - final ExifInterface exifInterface = new ExifInterface(); - try { - exifInterface.readExif(mBytes); - final Integer orientationValue = - exifInterface.getTagIntValue(ExifInterface.TAG_ORIENTATION); - if (orientationValue != null) { - orientation = orientationValue.intValue(); - } - // The thumbnail is of the full image, but we're cropping it, so just clear - // the thumbnail - exifInterface.setCompressedThumbnail((byte[]) null); - } catch (IOException e) { - // Couldn't get exif tags, not the end of the world - } - bitmap = BitmapFactory.decodeByteArray(mBytes, 0, mBytes.length); - final int clippedWidth; - final int clippedHeight; - if (ExifInterface.getOrientationParams(orientation).invertDimensions) { - Assert.equals(mWidth, bitmap.getHeight()); - Assert.equals(mHeight, bitmap.getWidth()); - clippedWidth = (int) (mHeight * mHeightPercent); - clippedHeight = mWidth; - } else { - Assert.equals(mWidth, bitmap.getWidth()); - Assert.equals(mHeight, bitmap.getHeight()); - clippedWidth = mWidth; - clippedHeight = (int) (mHeight * mHeightPercent); - } - final int offsetTop = (bitmap.getHeight() - clippedHeight) / 2; - final int offsetLeft = (bitmap.getWidth() - clippedWidth) / 2; - mWidth = clippedWidth; - mHeight = clippedHeight; - clippedBitmap = Bitmap.createBitmap(clippedWidth, clippedHeight, - Bitmap.Config.ARGB_8888); - clippedBitmap.setDensity(bitmap.getDensity()); - final Canvas clippedBitmapCanvas = new Canvas(clippedBitmap); - final Matrix matrix = new Matrix(); - matrix.postTranslate(-offsetLeft, -offsetTop); - clippedBitmapCanvas.drawBitmap(bitmap, matrix, null /* paint */); - clippedBitmapCanvas.save(); - // EXIF data can take a big chunk of the file size and is often cleared by the - // carrier, only store orientation since that's critical - ExifTag orientationTag = exifInterface.getTag(ExifInterface.TAG_ORIENTATION); - exifInterface.clearExif(); - exifInterface.setTag(orientationTag); - exifInterface.writeExif(clippedBitmap, outputStream); - } else { - outputStream.write(mBytes); - } - } catch (final IOException e) { - mOutputUri = null; - mException = e; - LogUtil.e(TAG, "Unable to persist image to temp storage " + e); - } finally { - if (bitmap != null) { - bitmap.recycle(); - } - - if (clippedBitmap != null) { - clippedBitmap.recycle(); - } - - if (outputStream != null) { - try { - outputStream.flush(); - } catch (final IOException e) { - mOutputUri = null; - mException = e; - LogUtil.e(TAG, "error trying to flush and close the outputStream" + e); - } finally { - try { - outputStream.close(); - } catch (final IOException e) { - // Do nothing. - } - } - } - } - return null; - } - - @Override - protected void onPostExecute(final Void aVoid) { - if (mOutputUri != null) { - mCallback.onMediaReady(mOutputUri, ContentType.IMAGE_JPEG, mWidth, mHeight); - } else { - Assert.notNull(mException); - mCallback.onMediaFailed(mException); - } - } -} diff --git a/src/com/android/messaging/ui/mediapicker/LevelTrackingMediaRecorder.java b/src/com/android/messaging/ui/mediapicker/LevelTrackingMediaRecorder.java index 06730a3b..b8a44247 100644 --- a/src/com/android/messaging/ui/mediapicker/LevelTrackingMediaRecorder.java +++ b/src/com/android/messaging/ui/mediapicker/LevelTrackingMediaRecorder.java @@ -32,26 +32,9 @@ /** * Wraps around the functionalities of MediaRecorder, performs routine setup for audio recording - * and updates the audio level to be displayed in UI. - * - * During the start and end of a recording session, we kick off a thread that polls for audio - * levels, and updates the thread-safe AudioLevelSource instance. Consumers may bind to the - * sound level by either polling from the level source, or register for a level change callback - * on the level source object. In Bugle, the UI element (SoundLevels) polls for the sound level - * on the UI thread by using animation ticks and invalidating itself. - * - * Aside from tracking sound levels, this also encapsulates the functionality to save the file - * to the scratch space. The saved file is returned by calling stopRecording(). + * and saves the file to scratch space. The saved file is returned by calling stopRecording(). */ public class LevelTrackingMediaRecorder { - // We refresh sound level every 100ms during a recording session. - private static final int REFRESH_INTERVAL_MILLIS = 100; - - // The native amplitude returned from MediaRecorder ranges from 0~32768 (unfortunately, this - // is not a constant that's defined anywhere, but the framework's Recorder app is using the - // same hard-coded number). Therefore, a constant is needed in order to make it 0~100. - private static final int MAX_AMPLITUDE_FACTOR = 32768 / 100; - // We want to limit the max audio file size by the max message size allowed by MmsConfig, // plus multiplied by this fudge ratio to guarantee that we don't go over limit. private static final float MAX_SIZE_RATIO = 0.8f; @@ -62,20 +45,10 @@ public class LevelTrackingMediaRecorder { private static final int MEDIA_RECORDER_OUTPUT_FORMAT = MediaRecorder.OutputFormat.THREE_GPP; private static final int MEDIA_RECORDER_AUDIO_ENCODER = MediaRecorder.AudioEncoder.AMR_NB; - private final AudioLevelSource mLevelSource; - private Thread mRefreshLevelThread; private MediaRecorder mRecorder; private Uri mOutputUri; private ParcelFileDescriptor mOutputFD; - public LevelTrackingMediaRecorder() { - mLevelSource = new AudioLevelSource(); - } - - public AudioLevelSource getLevelSource() { - return mLevelSource; - } - /** * @return if we are currently in a recording session. */ @@ -113,7 +86,6 @@ public boolean startRecording(final MediaRecorder.OnErrorListener errorListener, mRecorder.setOnInfoListener(infoListener); mRecorder.prepare(); mRecorder.start(); - startTrackingSoundLevel(); return true; } catch (final Exception e) { // There may be a device failure or I/O failure, record the error but @@ -174,50 +146,6 @@ public void run() { mOutputFD = null; } - stopTrackingSoundLevel(); return mOutputUri; } - - private int getAmplitude() { - synchronized (LevelTrackingMediaRecorder.class) { - if (mRecorder != null) { - final int maxAmplitude = mRecorder.getMaxAmplitude() / MAX_AMPLITUDE_FACTOR; - return Math.min(maxAmplitude, 100); - } else { - return 0; - } - } - } - - private void startTrackingSoundLevel() { - stopTrackingSoundLevel(); - mRefreshLevelThread = new Thread() { - @Override - public void run() { - try { - while (true) { - synchronized (LevelTrackingMediaRecorder.class) { - if (mRecorder != null) { - mLevelSource.setSpeechLevel(getAmplitude()); - } else { - // The recording session is over, finish the thread. - return; - } - } - Thread.sleep(REFRESH_INTERVAL_MILLIS); - } - } catch (final InterruptedException e) { - Thread.currentThread().interrupt(); - } - } - }; - mRefreshLevelThread.start(); - } - - private void stopTrackingSoundLevel() { - if (mRefreshLevelThread != null && mRefreshLevelThread.isAlive()) { - mRefreshLevelThread.interrupt(); - mRefreshLevelThread = null; - } - } } diff --git a/src/com/android/messaging/ui/mediapicker/MediaChooser.java b/src/com/android/messaging/ui/mediapicker/MediaChooser.java deleted file mode 100644 index ea206c79..00000000 --- a/src/com/android/messaging/ui/mediapicker/MediaChooser.java +++ /dev/null @@ -1,220 +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.mediapicker; - -import android.content.Context; -import android.content.Intent; -import android.view.LayoutInflater; -import android.view.Menu; -import android.view.MenuInflater; -import android.view.MenuItem; -import android.view.View; -import android.view.ViewGroup; -import android.widget.ImageButton; - -import com.android.messaging.R; -import com.android.messaging.datamodel.binding.ImmutableBindingRef; -import com.android.messaging.datamodel.data.DraftMessageData.DraftMessageSubscriptionDataProvider; -import com.android.messaging.datamodel.data.MediaPickerData; -import com.android.messaging.ui.BasePagerViewHolder; -import com.android.messaging.util.Assert; - -import androidx.appcompat.app.ActionBar; -import androidx.fragment.app.FragmentManager; - -abstract class MediaChooser extends BasePagerViewHolder - implements DraftMessageSubscriptionDataProvider { - /** The media picker that the chooser is hosted in */ - protected final MediaPicker mMediaPicker; - - /** Referencing the main media picker binding to perform data loading */ - protected final ImmutableBindingRef mBindingRef; - - /** True if this is the selected chooser */ - protected boolean mSelected; - - /** True if this chooser is open */ - protected boolean mOpen; - - /** The button to show in the tab strip */ - private ImageButton mTabButton; - - /** Used by subclasses to indicate that no loader is required from the data model in order for - * this chooser to function. - */ - public static final int NO_LOADER_REQUIRED = -1; - - /** - * Initializes a new instance of the Chooser class - * @param mediaPicker The media picker that the chooser is hosted in - */ - MediaChooser(final MediaPicker mediaPicker) { - Assert.notNull(mediaPicker); - mMediaPicker = mediaPicker; - mBindingRef = mediaPicker.getMediaPickerDataBinding(); - mSelected = false; - } - - protected void setSelected(final boolean selected) { - mSelected = selected; - if (selected) { - // If we're selected, it must be open - mOpen = true; - } - if (mTabButton != null) { - mTabButton.setSelected(selected); - mTabButton.setAlpha(selected ? 1 : 0.5f); - } - } - - ImageButton getTabButton() { - return mTabButton; - } - - void onCreateTabButton(final LayoutInflater inflater, final ViewGroup parent) { - mTabButton = (ImageButton) inflater.inflate( - R.layout.mediapicker_tab_button, - parent, - false /* addToParent */); - mTabButton.setImageResource(getIconResource()); - mTabButton.setContentDescription( - inflater.getContext().getResources().getString(getIconDescriptionResource())); - setSelected(mSelected); - mTabButton.setOnClickListener(new View.OnClickListener() { - @Override - public void onClick(final View view) { - mMediaPicker.selectChooser(MediaChooser.this); - } - }); - } - - protected Context getContext() { - return mMediaPicker.getActivity(); - } - - protected FragmentManager getFragmentManager() { - return mMediaPicker.getChildFragmentManager(); - } - - protected LayoutInflater getLayoutInflater() { - return LayoutInflater.from(getContext()); - } - - /** Allows the chooser to handle full screen change */ - void onFullScreenChanged(final boolean fullScreen) {} - - /** Allows the chooser to handle the chooser being opened or closed */ - void onOpenedChanged(final boolean open) { - mOpen = open; - } - - /** @return The bit field of media types that this chooser can pick */ - public abstract int getSupportedMediaTypes(); - - /** @return The resource id of the icon for the chooser */ - abstract int getIconResource(); - - /** @return The resource id of the string to use for the accessibility text of the icon */ - abstract int getIconDescriptionResource(); - - /** - * Sets up the action bar to show the current state of the full-screen chooser - * @param actionBar The action bar to populate - */ - void updateActionBar(final ActionBar actionBar) { - final int actionBarTitleResId = getActionBarTitleResId(); - if (actionBarTitleResId == 0) { - actionBar.hide(); - } else { - actionBar.setCustomView(null); - actionBar.setDisplayOptions(ActionBar.DISPLAY_SHOW_TITLE); - actionBar.setDisplayHomeAsUpEnabled(true); - actionBar.show(); - // Use X instead of <- in the action bar - actionBar.setHomeAsUpIndicator(R.drawable.ic_remove_small_light); - actionBar.setTitle(actionBarTitleResId); - } - } - - /** - * Returns the resource Id used for the action bar title. - */ - abstract int getActionBarTitleResId(); - - /** - * Throws an exception if the media chooser object doesn't require data support. - */ - public void onDataUpdated(final Object data, final int loaderId) { - throw new IllegalStateException(); - } - - /** - * Called by the MediaPicker to determine whether this panel can be swiped down further. If - * not, then a swipe down gestured will be captured by the MediaPickerPanel to shrink the - * entire panel. - */ - public boolean canSwipeDown() { - return false; - } - - /** - * Typically the media picker is closed when the IME is opened, but this allows the chooser to - * specify that showing the IME is okay while the chooser is up - */ - public boolean canShowIme() { - return false; - } - - public boolean onBackPressed() { - return false; - } - - public void onCreateOptionsMenu(final MenuInflater inflater, final Menu menu) { - } - - public boolean onOptionsItemSelected(final MenuItem item) { - return false; - } - - public void setThemeColor(final int color) { - } - - /** - * Returns true if the chooser is owning any incoming touch events, so that the media picker - * panel won't process it and slide the panel. - */ - public boolean isHandlingTouch() { - return false; - } - - public void stopTouchHandling() { - } - - protected void onActivityResult( - final int requestCode, final int resultCode, final Intent data) {} - - @Override - public int getConversationSelfSubId() { - return mMediaPicker.getConversationSelfSubId(); - } - - /** Optional activity life-cycle methods to be overridden by subclasses */ - public void onPause() { } - public void onResume() { } - protected void onRequestPermissionsResult( - final int requestCode, final String permissions[], final int[] grantResults) { } -} diff --git a/src/com/android/messaging/ui/mediapicker/MediaPicker.java b/src/com/android/messaging/ui/mediapicker/MediaPicker.java deleted file mode 100644 index 0ffa2a6d..00000000 --- a/src/com/android/messaging/ui/mediapicker/MediaPicker.java +++ /dev/null @@ -1,719 +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.mediapicker; - -import android.app.Activity; -import android.content.Context; -import android.content.Intent; -import android.os.Bundle; -import android.os.Handler; - -import androidx.fragment.app.Fragment; -import androidx.loader.app.LoaderManager; -import androidx.viewpager.widget.PagerAdapter; -import androidx.viewpager.widget.ViewPager; -import androidx.appcompat.app.ActionBar; -import android.view.LayoutInflater; -import android.view.Menu; -import android.view.MenuInflater; -import android.view.MenuItem; -import android.view.View; -import android.view.ViewGroup; -import android.widget.ImageButton; -import android.widget.LinearLayout; - -import com.android.messaging.Factory; -import com.android.messaging.R; -import com.android.messaging.datamodel.DataModel; -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.DraftMessageData; -import com.android.messaging.datamodel.data.MediaPickerData; -import com.android.messaging.datamodel.data.MessagePartData; -import com.android.messaging.datamodel.data.PendingAttachmentData; -import com.android.messaging.datamodel.data.DraftMessageData.DraftMessageSubscriptionDataProvider; -import com.android.messaging.ui.BugleActionBarActivity; -import com.android.messaging.ui.FixedViewPagerAdapter; -import com.android.messaging.util.AccessibilityUtil; -import com.android.messaging.util.Assert; -import com.android.messaging.util.UiUtils; -import com.google.common.annotations.VisibleForTesting; - -import java.util.ArrayList; -import java.util.Collection; -import java.util.List; - -/** - * Fragment used to select or capture media to be added to the message - */ -public class MediaPicker extends Fragment implements DraftMessageSubscriptionDataProvider { - /** The listener interface for events from the media picker */ - public interface MediaPickerListener { - /** Called when the media picker is opened so the host can accommodate the UI */ - void onOpened(); - - /** - * Called when the media picker goes into or leaves full screen mode so the host can - * accommodate the fullscreen UI - */ - void onFullScreenChanged(boolean fullScreen); - - /** - * Called when the user selects one or more items - * @param items The list of items which were selected - */ - void onItemsSelected(Collection items, boolean dismissMediaPicker); - - /** - * Called when the user unselects one item. - */ - void onItemUnselected(MessagePartData item); - - /** - * Called when the media picker is closed. Always called immediately after onItemsSelected - */ - void onDismissed(); - - /** - * Called when media item selection is confirmed in a multi-select action. - */ - void onConfirmItemSelection(); - - /** - * Called when a pending attachment is added. - * @param pendingItem the pending attachment data being loaded. - */ - void onPendingItemAdded(PendingAttachmentData pendingItem); - - /** - * Called when a new media chooser is selected. - */ - void onChooserSelected(final int chooserIndex); - } - - /** The tag used when registering and finding this fragment */ - public static final String FRAGMENT_TAG = "mediapicker"; - - // Media type constants that the media picker supports - public static final int MEDIA_TYPE_DEFAULT = 0x0000; - public static final int MEDIA_TYPE_NONE = 0x0000; - public static final int MEDIA_TYPE_IMAGE = 0x0001; - public static final int MEDIA_TYPE_VIDEO = 0x0002; - public static final int MEDIA_TYPE_AUDIO = 0x0004; - public static final int MEDIA_TYPE_VCARD = 0x0008; - public static final int MEDIA_TYPE_LOCATION = 0x0010; - private static final int MEDA_TYPE_INVALID = 0x0020; - public static final int MEDIA_TYPE_ALL = 0xFFFF; - - /** The listener to call when events occur */ - private MediaPickerListener mListener; - - /** The handler used to dispatch calls to the listener */ - private Handler mListenerHandler; - - /** The bit flags of media types supported */ - private int mSupportedMediaTypes; - - /** The list of choosers which could be within the media picker */ - private final MediaChooser[] mChoosers; - - /** The list of currently enabled choosers */ - private final ArrayList mEnabledChoosers; - - /** The currently selected chooser */ - private MediaChooser mSelectedChooser; - - /** The main panel that controls the custom layout */ - private MediaPickerPanel mMediaPickerPanel; - - /** The linear layout that holds the icons to select individual chooser tabs */ - private LinearLayout mTabStrip; - - /** The view pager to swap between choosers */ - private ViewPager mViewPager; - - /** The current pager adapter for the view pager */ - private FixedViewPagerAdapter mPagerAdapter; - - /** True if the media picker is visible */ - private boolean mOpen; - - /** The theme color to use to make the media picker match the rest of the UI */ - private int mThemeColor; - - @VisibleForTesting - final Binding mBinding = BindingBase.createBinding(this); - - /** Provides subscription-related data to access per-subscription configurations. */ - private DraftMessageSubscriptionDataProvider mSubscriptionDataProvider; - - /** Provides access to DraftMessageData associated with the current conversation */ - private ImmutableBindingRef mDraftMessageDataModel; - - public MediaPicker() { - this(Factory.get().getApplicationContext()); - } - - public MediaPicker(final Context context) { - mBinding.bind(DataModel.get().createMediaPickerData(context)); - mEnabledChoosers = new ArrayList(); - mChoosers = new MediaChooser[] { - new CameraMediaChooser(this), - new GalleryMediaChooser(this), - new AudioMediaChooser(this), - new ContactMediaChooser(this), - }; - - mOpen = false; - setSupportedMediaTypes(MEDIA_TYPE_ALL); - } - - private boolean mIsAttached; - private int mStartingMediaTypeOnAttach = MEDA_TYPE_INVALID; - private boolean mAnimateOnAttach; - - @Override - public void onAttach (final Activity activity) { - super.onAttach(activity); - mIsAttached = true; - if (mStartingMediaTypeOnAttach != MEDA_TYPE_INVALID) { - // open() was previously called. Do the pending open now. - doOpen(mStartingMediaTypeOnAttach, mAnimateOnAttach); - } - } - - @Override - public void onCreate(final Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - mBinding.getData().init(LoaderManager.getInstance(this)); - } - - @Override - public View onCreateView( - final LayoutInflater inflater, - final ViewGroup container, - final Bundle savedInstanceState) { - mMediaPickerPanel = (MediaPickerPanel) inflater.inflate( - R.layout.mediapicker_fragment, - container, - false); - mMediaPickerPanel.setMediaPicker(this); - mTabStrip = (LinearLayout) mMediaPickerPanel.findViewById(R.id.mediapicker_tabstrip); - mTabStrip.setBackgroundColor(mThemeColor); - for (final MediaChooser chooser : mChoosers) { - chooser.onCreateTabButton(inflater, mTabStrip); - final boolean enabled = (chooser.getSupportedMediaTypes() & mSupportedMediaTypes) != - MEDIA_TYPE_NONE; - final ImageButton tabButton = chooser.getTabButton(); - if (tabButton != null) { - tabButton.setVisibility(enabled ? View.VISIBLE : View.GONE); - mTabStrip.addView(tabButton); - } - } - - mViewPager = (ViewPager) mMediaPickerPanel.findViewById(R.id.mediapicker_view_pager); - mViewPager.setOnPageChangeListener(new ViewPager.OnPageChangeListener() { - @Override - public void onPageScrolled( - final int position, - final float positionOffset, - final int positionOffsetPixels) { - } - - @Override - public void onPageSelected(int position) { - // The position returned is relative to if we are in RtL mode. This class never - // switches the indices of the elements if we are in RtL mode so we need to - // translate the index back. For example, if the user clicked the item most to the - // right in RtL mode we would want the index to appear as 0 here, however the - // position returned would the last possible index. - if (UiUtils.isRtlMode()) { - position = mEnabledChoosers.size() - 1 - position; - } - selectChooser(mEnabledChoosers.get(position)); - } - - @Override - public void onPageScrollStateChanged(final int state) { - } - }); - // Camera initialization is expensive, so don't realize offscreen pages if not needed. - mViewPager.setOffscreenPageLimit(0); - mViewPager.setAdapter(mPagerAdapter); - final boolean isTouchExplorationEnabled = AccessibilityUtil.isTouchExplorationEnabled( - getActivity()); - mMediaPickerPanel.setFullScreenOnly(isTouchExplorationEnabled); - mMediaPickerPanel.setExpanded(mOpen, true, mEnabledChoosers.indexOf(mSelectedChooser)); - return mMediaPickerPanel; - } - - @Override - public void onPause() { - super.onPause(); - CameraManager.get().onPause(); - for (final MediaChooser chooser : mEnabledChoosers) { - chooser.onPause(); - } - } - - @Override - public void onResume() { - super.onResume(); - CameraManager.get().onResume(); - - for (final MediaChooser chooser : mEnabledChoosers) { - chooser.onResume(); - } - } - - @Override - public void onActivityResult(final int requestCode, final int resultCode, final Intent data) { - super.onActivityResult(requestCode, resultCode, data); - mSelectedChooser.onActivityResult(requestCode, resultCode, data); - } - - @Override - public void onDestroy() { - super.onDestroy(); - mBinding.unbind(); - } - - /** - * Sets the theme color to make the media picker match the surrounding UI - * @param themeColor The new theme color - */ - public void setConversationThemeColor(final int themeColor) { - mThemeColor = themeColor; - if (mTabStrip != null) { - mTabStrip.setBackgroundColor(mThemeColor); - } - - for (final MediaChooser chooser : mEnabledChoosers) { - chooser.setThemeColor(mThemeColor); - } - } - - /** - * Gets the current conversation theme color. - */ - public int getConversationThemeColor() { - return mThemeColor; - } - - public void setDraftMessageDataModel(final BindingBase draftBinding) { - mDraftMessageDataModel = Binding.createBindingReference(draftBinding); - } - - public ImmutableBindingRef getDraftMessageDataModel() { - return mDraftMessageDataModel; - } - - public void setSubscriptionDataProvider(final DraftMessageSubscriptionDataProvider provider) { - mSubscriptionDataProvider = provider; - } - - @Override - public int getConversationSelfSubId() { - return mSubscriptionDataProvider.getConversationSelfSubId(); - } - - /** - * Opens the media picker and optionally shows the chooser for the supplied media type - * @param startingMediaType The media type of the chooser to open if {@link #MEDIA_TYPE_DEFAULT} - * is used, then the default chooser from saved shared prefs is opened - */ - public void open(final int startingMediaType, final boolean animate) { - mOpen = true; - if (mIsAttached) { - doOpen(startingMediaType, animate); - } else { - // open() can get called immediately after the MediaPicker is created. In that case, - // we defer doing work as it may require an attached fragment (eg. calling - // Fragment#requestPermission) - mStartingMediaTypeOnAttach = startingMediaType; - mAnimateOnAttach = animate; - } - } - - private void doOpen(int startingMediaType, final boolean animate) { - final boolean isTouchExplorationEnabled = AccessibilityUtil.isTouchExplorationEnabled( - // getActivity() will be null at this point - Factory.get().getApplicationContext()); - - // If no specific starting type is specified (i.e. MEDIA_TYPE_DEFAULT), try to get the - // last opened chooser index from shared prefs. - if (startingMediaType == MEDIA_TYPE_DEFAULT) { - final int selectedChooserIndex = mBinding.getData().getSelectedChooserIndex(); - if (selectedChooserIndex >= 0 && selectedChooserIndex < mEnabledChoosers.size()) { - selectChooser(mEnabledChoosers.get(selectedChooserIndex)); - } else { - // This is the first time the picker is being used - if (isTouchExplorationEnabled) { - // Accessibility defaults to audio attachment mode. - startingMediaType = MEDIA_TYPE_AUDIO; - } - } - } - - if (mSelectedChooser == null) { - for (final MediaChooser chooser : mEnabledChoosers) { - if (startingMediaType == MEDIA_TYPE_DEFAULT || - (startingMediaType & chooser.getSupportedMediaTypes()) != MEDIA_TYPE_NONE) { - selectChooser(chooser); - break; - } - } - } - - if (mSelectedChooser == null) { - // Fall back to the first chooser. - selectChooser(mEnabledChoosers.get(0)); - } - - if (mMediaPickerPanel != null) { - mMediaPickerPanel.setFullScreenOnly(isTouchExplorationEnabled); - mMediaPickerPanel.setExpanded(true, animate, - mEnabledChoosers.indexOf(mSelectedChooser)); - } - } - - /** @return True if the media picker is open */ - public boolean isOpen() { - return mOpen; - } - - /** - * Sets the list of media types to allow the user to select - * @param mediaTypes The bit flags of media types to allow. Can be any combination of the - * MEDIA_TYPE_* values - */ - void setSupportedMediaTypes(final int mediaTypes) { - mSupportedMediaTypes = mediaTypes; - mEnabledChoosers.clear(); - boolean selectNextChooser = false; - for (final MediaChooser chooser : mChoosers) { - final boolean enabled = (chooser.getSupportedMediaTypes() & mSupportedMediaTypes) != - MEDIA_TYPE_NONE; - if (enabled) { - // TODO Add a way to inform the chooser which media types are supported - mEnabledChoosers.add(chooser); - if (selectNextChooser) { - selectChooser(chooser); - selectNextChooser = false; - } - } else if (mSelectedChooser == chooser) { - selectNextChooser = true; - } - final ImageButton tabButton = chooser.getTabButton(); - if (tabButton != null) { - tabButton.setVisibility(enabled ? View.VISIBLE : View.GONE); - } - } - - if (selectNextChooser && mEnabledChoosers.size() > 0) { - selectChooser(mEnabledChoosers.get(0)); - } - final MediaChooser[] enabledChoosers = new MediaChooser[mEnabledChoosers.size()]; - mEnabledChoosers.toArray(enabledChoosers); - mPagerAdapter = new FixedViewPagerAdapter(enabledChoosers); - if (mViewPager != null) { - mViewPager.setAdapter(mPagerAdapter); - } - - // Only rebind data if we are currently bound. Otherwise, we must have not - // bound to any data yet and should wait until onCreate() to bind data. - if (mBinding.isBound() && getActivity() != null) { - mBinding.unbind(); - mBinding.bind(DataModel.get().createMediaPickerData(getActivity())); - mBinding.getData().init(LoaderManager.getInstance(this)); - } - } - - ViewPager getViewPager() { - return mViewPager; - } - - /** Hides the media picker, and frees up any resources it’s using */ - public void dismiss(final boolean animate) { - mOpen = false; - if (mMediaPickerPanel != null) { - mMediaPickerPanel.setExpanded(false, animate, MediaPickerPanel.PAGE_NOT_SET); - } - mSelectedChooser = null; - } - - /** - * Sets the listener for the media picker events - * @param listener The listener which will receive events - */ - public void setListener(final MediaPickerListener listener) { - Assert.isMainThread(); - mListener = listener; - mListenerHandler = listener != null ? new Handler() : null; - } - - /** @return True if the media picker is in full-screen mode */ - public boolean isFullScreen() { - return mMediaPickerPanel != null && mMediaPickerPanel.isFullScreen(); - } - - public void setFullScreen(final boolean fullScreen) { - mMediaPickerPanel.setFullScreenView(fullScreen, true); - } - - public void updateActionBar(final ActionBar actionBar) { - if (getActivity() == null) { - return; - } - if (isFullScreen() && mSelectedChooser != null) { - mSelectedChooser.updateActionBar(actionBar); - } else { - actionBar.hide(); - } - } - - /** - * Selects a new chooser - * @param newSelectedChooser The newly selected chooser - */ - void selectChooser(final MediaChooser newSelectedChooser) { - if (mSelectedChooser == newSelectedChooser) { - return; - } - - if (mSelectedChooser != null) { - mSelectedChooser.setSelected(false); - } - mSelectedChooser = newSelectedChooser; - if (mSelectedChooser != null) { - mSelectedChooser.setSelected(true); - } - - final int chooserIndex = mEnabledChoosers.indexOf(mSelectedChooser); - if (mViewPager != null) { - mViewPager.setCurrentItem(chooserIndex, true /* smoothScroll */); - } - - if (isFullScreen()) { - invalidateOptionsMenu(); - } - - // Save the newly selected chooser's index so we may directly switch to it the - // next time user opens the media picker. - mBinding.getData().saveSelectedChooserIndex(chooserIndex); - if (mMediaPickerPanel != null) { - mMediaPickerPanel.onChooserChanged(); - } - dispatchChooserSelected(chooserIndex); - } - - public boolean canShowIme() { - if (mSelectedChooser != null) { - return mSelectedChooser.canShowIme(); - } - return false; - } - - public boolean onBackPressed() { - return mSelectedChooser != null && mSelectedChooser.onBackPressed(); - } - - void invalidateOptionsMenu() { - ((BugleActionBarActivity) getActivity()).supportInvalidateOptionsMenu(); - } - - void dispatchOpened() { - setHasOptionsMenu(false); - mOpen = true; - mPagerAdapter.notifyDataSetChanged(); - if (mListener != null) { - mListenerHandler.post(new Runnable() { - @Override - public void run() { - mListener.onOpened(); - } - }); - } - if (mSelectedChooser != null) { - mSelectedChooser.onFullScreenChanged(false); - mSelectedChooser.onOpenedChanged(true); - } - } - - void dispatchDismissed() { - setHasOptionsMenu(false); - mOpen = false; - if (mListener != null) { - mListenerHandler.post(new Runnable() { - @Override - public void run() { - mListener.onDismissed(); - } - }); - } - if (mSelectedChooser != null) { - mSelectedChooser.onOpenedChanged(false); - } - } - - void dispatchFullScreen(final boolean fullScreen) { - setHasOptionsMenu(fullScreen); - if (mListener != null) { - mListenerHandler.post(new Runnable() { - @Override - public void run() { - mListener.onFullScreenChanged(fullScreen); - } - }); - } - if (mSelectedChooser != null) { - mSelectedChooser.onFullScreenChanged(fullScreen); - } - } - - void dispatchItemsSelected(final MessagePartData item, final boolean dismissMediaPicker) { - final List items = new ArrayList(1); - items.add(item); - dispatchItemsSelected(items, dismissMediaPicker); - } - - void dispatchItemsSelected(final Collection items, - final boolean dismissMediaPicker) { - if (mListener != null) { - mListenerHandler.post(new Runnable() { - @Override - public void run() { - mListener.onItemsSelected(items, dismissMediaPicker); - } - }); - } - - if (isFullScreen() && !dismissMediaPicker) { - invalidateOptionsMenu(); - } - } - - void dispatchItemUnselected(final MessagePartData item) { - if (mListener != null) { - mListenerHandler.post(new Runnable() { - @Override - public void run() { - mListener.onItemUnselected(item); - } - }); - } - - if (isFullScreen()) { - invalidateOptionsMenu(); - } - } - - void dispatchConfirmItemSelection() { - if (mListener != null) { - mListenerHandler.post(new Runnable() { - @Override - public void run() { - mListener.onConfirmItemSelection(); - } - }); - } - } - - void dispatchPendingItemAdded(final PendingAttachmentData pendingItem) { - if (mListener != null) { - mListenerHandler.post(new Runnable() { - @Override - public void run() { - mListener.onPendingItemAdded(pendingItem); - } - }); - } - - if (isFullScreen()) { - invalidateOptionsMenu(); - } - } - - void dispatchChooserSelected(final int chooserIndex) { - if (mListener != null) { - mListenerHandler.post(new Runnable() { - @Override - public void run() { - mListener.onChooserSelected(chooserIndex); - } - }); - } - } - - public boolean canSwipeDownChooser() { - return mSelectedChooser == null ? false : mSelectedChooser.canSwipeDown(); - } - - public boolean isChooserHandlingTouch() { - return mSelectedChooser == null ? false : mSelectedChooser.isHandlingTouch(); - } - - public void stopChooserTouchHandling() { - if (mSelectedChooser != null) { - mSelectedChooser.stopTouchHandling(); - } - } - - boolean getChooserShowsActionBarInFullScreen() { - return mSelectedChooser == null ? false : mSelectedChooser.getActionBarTitleResId() != 0; - } - - @Override - public void onCreateOptionsMenu(final Menu menu, final MenuInflater inflater) { - if (mSelectedChooser != null) { - mSelectedChooser.onCreateOptionsMenu(inflater, menu); - } - } - - @Override - public boolean onOptionsItemSelected(final MenuItem item) { - return (mSelectedChooser != null && mSelectedChooser.onOptionsItemSelected(item)) || - super.onOptionsItemSelected(item); - } - - PagerAdapter getPagerAdapter() { - return mPagerAdapter; - } - - public void resetViewHolderState() { - mPagerAdapter.resetState(); - } - - public ImmutableBindingRef getMediaPickerDataBinding() { - return BindingBase.createBindingReference(mBinding); - } - - protected static final int CAMERA_PERMISSION_REQUEST_CODE = 1; - protected static final int LOCATION_PERMISSION_REQUEST_CODE = 2; - protected static final int RECORD_AUDIO_PERMISSION_REQUEST_CODE = 3; - protected static final int GALLERY_PERMISSION_REQUEST_CODE = 4; - protected static final int READ_CONTACT_PERMISSION_REQUEST_CODE = 5; - - @Override - public void onRequestPermissionsResult( - final int requestCode, final String permissions[], final int[] grantResults) { - if (mSelectedChooser != null) { - mSelectedChooser.onRequestPermissionsResult(requestCode, permissions, grantResults); - } - } -} diff --git a/src/com/android/messaging/ui/mediapicker/MediaPickerGridView.java b/src/com/android/messaging/ui/mediapicker/MediaPickerGridView.java deleted file mode 100644 index cc3a4a1e..00000000 --- a/src/com/android/messaging/ui/mediapicker/MediaPickerGridView.java +++ /dev/null @@ -1,44 +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.mediapicker; - -import android.content.Context; -import android.util.AttributeSet; -import android.widget.GridView; - -public class MediaPickerGridView extends GridView { - - public MediaPickerGridView(final Context context, final AttributeSet attrs) { - super(context, attrs); - } - - /** - * Returns if the grid view can be swiped down further. It cannot be swiped down - * if there's no item or if we are already at the top. - */ - public boolean canSwipeDown() { - if (getAdapter() == null || getAdapter().getCount() == 0 || getChildCount() == 0) { - return false; - } - - final int firstVisiblePosition = getFirstVisiblePosition(); - if (firstVisiblePosition == 0 && getChildAt(0).getTop() >= 0) { - return false; - } - return true; - } -} diff --git a/src/com/android/messaging/ui/mediapicker/MediaPickerPanel.java b/src/com/android/messaging/ui/mediapicker/MediaPickerPanel.java deleted file mode 100644 index 4460a512..00000000 --- a/src/com/android/messaging/ui/mediapicker/MediaPickerPanel.java +++ /dev/null @@ -1,555 +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.mediapicker; - -import android.content.Context; -import android.content.res.Resources; -import android.os.Handler; -import android.util.AttributeSet; -import android.view.MotionEvent; -import android.view.View; -import android.view.ViewConfiguration; -import android.view.ViewGroup; -import android.view.animation.Animation; -import android.view.animation.Transformation; -import android.widget.LinearLayout; - -import com.android.messaging.R; -import com.android.messaging.ui.PagingAwareViewPager; -import com.android.messaging.util.UiUtils; - -/** - * Custom layout panel which makes the MediaPicker animations seamless and synchronized - * Designed to be very specific to the MediaPicker's usage - */ -public class MediaPickerPanel extends ViewGroup { - /** - * The window of time in which we might to decide to reinterpret the intent of a gesture. - */ - private static final long TOUCH_RECAPTURE_WINDOW_MS = 500L; - - // The two view components to layout - private LinearLayout mTabStrip; - private boolean mFullScreenOnly; - private PagingAwareViewPager mViewPager; - - /** - * True if the MediaPicker is full screen or animating into it - */ - private boolean mFullScreen; - - /** - * True if the MediaPicker is open at all - */ - private boolean mExpanded; - - /** - * The current desired height of the MediaPicker. This property may be animated and the - * measure pass uses it to determine what size the components are. - */ - private int mCurrentDesiredHeight; - - private final Handler mHandler = new Handler(); - - /** - * The media picker for dispatching events to the MediaPicker's listener - */ - private MediaPicker mMediaPicker; - - /** - * The computed default "half-screen" height of the view pager in px - */ - private final int mDefaultViewPagerHeight; - - /** - * The action bar height used to compute the padding on the view pager when it's full screen. - */ - private final int mActionBarHeight; - - private TouchHandler mTouchHandler; - - static final int PAGE_NOT_SET = -1; - - public MediaPickerPanel(final Context context, final AttributeSet attrs) { - super(context, attrs); - // Cache the computed dimension - mDefaultViewPagerHeight = getResources().getDimensionPixelSize( - R.dimen.mediapicker_default_chooser_height); - mActionBarHeight = getResources().getDimensionPixelSize(R.dimen.action_bar_height); - } - - @Override - protected void onFinishInflate() { - super.onFinishInflate(); - mTabStrip = (LinearLayout) findViewById(R.id.mediapicker_tabstrip); - mViewPager = (PagingAwareViewPager) findViewById(R.id.mediapicker_view_pager); - mTouchHandler = new TouchHandler(); - setOnTouchListener(mTouchHandler); - mViewPager.setOnTouchListener(mTouchHandler); - - // Make sure full screen mode is updated in landscape mode change when the panel is open. - addOnLayoutChangeListener(new OnLayoutChangeListener() { - private boolean mLandscapeMode = UiUtils.isLandscapeMode(); - - @Override - public void onLayoutChange(View v, int left, int top, int right, int bottom, - int oldLeft, int oldTop, int oldRight, int oldBottom) { - final boolean newLandscapeMode = UiUtils.isLandscapeMode(); - if (mLandscapeMode != newLandscapeMode) { - mLandscapeMode = newLandscapeMode; - if (mExpanded) { - setExpanded(mExpanded, false /* animate */, mViewPager.getCurrentItem(), - true /* force */); - } - } - } - }); - } - - @Override - protected void onMeasure(final int widthMeasureSpec, final int heightMeasureSpec) { - int requestedHeight = MeasureSpec.getSize(heightMeasureSpec); - if (mMediaPicker.getChooserShowsActionBarInFullScreen()) { - requestedHeight -= mActionBarHeight; - } - int desiredHeight = Math.min(mCurrentDesiredHeight, requestedHeight); - if (mExpanded && desiredHeight == 0) { - // If we want to be shown, we have to have a non-0 height. Returning a height of 0 will - // cause the framework to abort the animation from 0, so we must always have some - // height once we start expanding - desiredHeight = 1; - } else if (!mExpanded && desiredHeight == 0) { - mViewPager.setVisibility(View.GONE); - mViewPager.setAdapter(null); - } - - measureChild(mTabStrip, widthMeasureSpec, heightMeasureSpec); - - int tabStripHeight; - if (requiresFullScreen()) { - // Ensure that the tab strip is always visible, even in full screen. - tabStripHeight = mTabStrip.getMeasuredHeight(); - } else { - // Slide out the tab strip at the end of the animation to full screen. - tabStripHeight = Math.min(mTabStrip.getMeasuredHeight(), - requestedHeight - desiredHeight); - } - - // If we are animating and have an interim desired height, use the default height. We can't - // take the max here as on some devices the mDefaultViewPagerHeight may be too big in - // landscape mode after animation. - final int tabAdjustedDesiredHeight = desiredHeight - tabStripHeight; - final int viewPagerHeight = - tabAdjustedDesiredHeight <= 1 ? mDefaultViewPagerHeight : tabAdjustedDesiredHeight; - - int viewPagerHeightMeasureSpec = MeasureSpec.makeMeasureSpec( - viewPagerHeight, MeasureSpec.EXACTLY); - measureChild(mViewPager, widthMeasureSpec, viewPagerHeightMeasureSpec); - setMeasuredDimension(mViewPager.getMeasuredWidth(), desiredHeight); - } - - @Override - protected void onLayout(final boolean changed, final int left, final int top, final int right, - final int bottom) { - int y = top; - final int width = right - left; - - final int viewPagerHeight = mViewPager.getMeasuredHeight(); - mViewPager.layout(0, y, width, y + viewPagerHeight); - y += viewPagerHeight; - - mTabStrip.layout(0, y, width, y + mTabStrip.getMeasuredHeight()); - } - - void onChooserChanged() { - if (mFullScreen) { - setDesiredHeight(getDesiredHeight(), true); - } - } - - void setFullScreenOnly(boolean fullScreenOnly) { - mFullScreenOnly = fullScreenOnly; - } - - boolean isFullScreen() { - return mFullScreen; - } - - void setMediaPicker(final MediaPicker mediaPicker) { - mMediaPicker = mediaPicker; - } - - /** - * Get the desired height of the media picker panel for when the panel is not in motion (i.e. - * not being dragged by the user). - */ - private int getDesiredHeight() { - if (mFullScreen) { - int fullHeight = getContext().getResources().getDisplayMetrics().heightPixels; - 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 - fullHeight -= UiUtils.getMeasuredBoundsOnScreen(getRootView()).top; - } - if (mMediaPicker.getChooserShowsActionBarInFullScreen()) { - return fullHeight - mActionBarHeight; - } else { - return fullHeight; - } - } else if (mExpanded) { - return LayoutParams.WRAP_CONTENT; - } else { - return 0; - } - } - - private void setupViewPager(final int startingPage) { - mViewPager.setVisibility(View.VISIBLE); - if (startingPage >= 0 && startingPage < mMediaPicker.getPagerAdapter().getCount()) { - mViewPager.setAdapter(mMediaPicker.getPagerAdapter()); - mViewPager.setCurrentItem(startingPage); - } - updateViewPager(); - } - - /** - * Expand the media picker panel. Since we always set the pager adapter to null when the panel - * is collapsed, we need to restore the adapter and the starting page. - * @param expanded expanded or collapsed - * @param animate need animation - * @param startingPage the desired selected page to start - */ - void setExpanded(final boolean expanded, final boolean animate, final int startingPage) { - setExpanded(expanded, animate, startingPage, false /* force */); - } - - private void setExpanded(final boolean expanded, final boolean animate, final int startingPage, - final boolean force) { - if (expanded == mExpanded && !force) { - return; - } - mFullScreen = false; - mExpanded = expanded; - mHandler.post(new Runnable() { - @Override - public void run() { - setDesiredHeight(getDesiredHeight(), animate); - } - }); - if (expanded) { - setupViewPager(startingPage); - mMediaPicker.dispatchOpened(); - } else { - mMediaPicker.dispatchDismissed(); - } - - // Call setFullScreenView() when we are in landscape mode so it can go full screen as - // soon as it is expanded. - if (expanded && requiresFullScreen()) { - setFullScreenView(true, animate); - } - } - - private boolean requiresFullScreen() { - return mFullScreenOnly || UiUtils.isLandscapeMode(); - } - - private void setDesiredHeight(int height, final boolean animate) { - final int startHeight = mCurrentDesiredHeight; - if (height == LayoutParams.WRAP_CONTENT) { - height = measureHeight(); - } - clearAnimation(); - if (animate) { - final int deltaHeight = height - startHeight; - final Animation animation = new Animation() { - @Override - protected void applyTransformation(final float interpolatedTime, - final Transformation t) { - mCurrentDesiredHeight = (int) (startHeight + deltaHeight * interpolatedTime); - requestLayout(); - } - - @Override - public boolean willChangeBounds() { - return true; - } - }; - animation.setDuration(UiUtils.MEDIAPICKER_TRANSITION_DURATION); - animation.setInterpolator(UiUtils.EASE_OUT_INTERPOLATOR); - startAnimation(animation); - } else { - mCurrentDesiredHeight = height; - } - requestLayout(); - } - - /** - * @return The minimum total height of the view - */ - private int measureHeight() { - final int measureSpec = MeasureSpec.makeMeasureSpec(Integer.MAX_VALUE, MeasureSpec.AT_MOST); - measureChild(mTabStrip, measureSpec, measureSpec); - return mDefaultViewPagerHeight + mTabStrip.getMeasuredHeight(); - } - - /** - * Enters or leaves full screen view - * - * @param fullScreen True to enter full screen view, false to leave - * @param animate True to animate the transition - */ - void setFullScreenView(final boolean fullScreen, final boolean animate) { - if (fullScreen == mFullScreen) { - return; - } - - if (requiresFullScreen() && !fullScreen) { - setExpanded(false /* expanded */, true /* animate */, PAGE_NOT_SET); - return; - } - mFullScreen = fullScreen; - setDesiredHeight(getDesiredHeight(), animate); - mMediaPicker.dispatchFullScreen(mFullScreen); - updateViewPager(); - } - - /** - * ViewPager should have its paging disabled when in full screen mode. - */ - private void updateViewPager() { - mViewPager.setPagingEnabled(!mFullScreen); - } - - @Override - public boolean onInterceptTouchEvent(final MotionEvent ev) { - return mTouchHandler.onInterceptTouchEvent(ev) || super.onInterceptTouchEvent(ev); - } - - /** - * Helper class to handle touch events and swipe gestures - */ - private class TouchHandler implements OnTouchListener { - /** - * The height of the view when the touch press started - */ - private int mDownHeight = -1; - - /** - * True if the panel moved at all (changed height) during the drag - */ - private boolean mMoved = false; - - // The threshold constants converted from DP to px - private final float mFlingThresholdPx; - private final float mBigFlingThresholdPx; - - // The system defined pixel size to determine when a movement is considered a drag. - private final int mTouchSlop; - - /** - * A copy of the MotionEvent that started the drag/swipe gesture - */ - private MotionEvent mDownEvent; - - /** - * Whether we are currently moving down. We may not be able to move down in full screen - * mode when the child view can swipe down (such as a list view). - */ - private boolean mMovedDown = false; - - /** - * Indicates whether the child view contained in the panel can swipe down at the beginning - * of the drag event (i.e. the initial down). The MediaPanel can contain - * scrollable children such as a list view / grid view. If the child view can swipe down, - * We want to let the child view handle the scroll first instead of handling it ourselves. - */ - private boolean mCanChildViewSwipeDown = false; - - /** - * Necessary direction ratio for a fling to be considered in one direction this prevents - * horizontal swipes with small vertical components from triggering vertical swipe actions - */ - private static final float DIRECTION_RATIO = 1.1f; - - TouchHandler() { - final Resources resources = getContext().getResources(); - final ViewConfiguration configuration = ViewConfiguration.get(getContext()); - mFlingThresholdPx = resources.getDimensionPixelSize( - R.dimen.mediapicker_fling_threshold); - mBigFlingThresholdPx = resources.getDimensionPixelSize( - R.dimen.mediapicker_big_fling_threshold); - mTouchSlop = configuration.getScaledTouchSlop(); - } - - /** - * The media picker panel may contain scrollable children such as a GridView, which eats - * all touch events before we get to it. Therefore, we'd like to intercept these events - * before the children to determine if we should handle swiping down in full screen mode. - * In non-full screen mode, we should handle all vertical scrolling events and leave - * horizontal scrolling to the view pager. - */ - public boolean onInterceptTouchEvent(final MotionEvent ev) { - switch (ev.getActionMasked()) { - case MotionEvent.ACTION_DOWN: - // Never capture the initial down, so that the children may handle it - // as well. Let the touch handler know about the down event as well. - mTouchHandler.onTouch(MediaPickerPanel.this, ev); - - // Ask the MediaPicker whether the contained view can be swiped down. - // We record the value at the start of the drag to decide the swiping mode - // for the entire motion. - mCanChildViewSwipeDown = mMediaPicker.canSwipeDownChooser(); - return false; - - case MotionEvent.ACTION_MOVE: { - if (mMediaPicker.isChooserHandlingTouch()) { - if (shouldAllowRecaptureTouch(ev)) { - mMediaPicker.stopChooserTouchHandling(); - mViewPager.setPagingEnabled(true); - return false; - } - // If the chooser is claiming ownership on all touch events, then we - // shouldn't try to handle them (neither should the view pager). - mViewPager.setPagingEnabled(false); - return false; - } else if (mCanChildViewSwipeDown) { - // Never capture event if the child view can swipe down. - return false; - } else if (!mFullScreen && mMoved) { - // When we are not fullscreen, we own any vertical drag motion. - return true; - } else if (mMovedDown) { - // We are currently handling the down swipe ourselves, so always - // capture this event. - return true; - } else { - // The current interaction mode is undetermined, so always let the - // touch handler know about this event. However, don't capture this - // event so the child may handle it as well. - mTouchHandler.onTouch(MediaPickerPanel.this, ev); - - // Capture the touch event from now on if we are handling the drag. - return mFullScreen ? mMovedDown : mMoved; - } - } - } - return false; - } - - /** - * Determine whether we think the user is actually trying to expand or slide despite the - * fact that they touched first on a chooser that captured the input. - */ - private boolean shouldAllowRecaptureTouch(MotionEvent ev) { - final long elapsedMs = ev.getEventTime() - ev.getDownTime(); - if (mDownEvent == null || elapsedMs == 0 || elapsedMs > TOUCH_RECAPTURE_WINDOW_MS) { - // Either we don't have info to decide or it's been long enough that we no longer - // want to reinterpret user intent. - return false; - } - final float dx = ev.getRawX() - mDownEvent.getRawX(); - final float dy = ev.getRawY() - mDownEvent.getRawY(); - final float dt = elapsedMs / 1000.0f; - final float maxAbsDelta = Math.max(Math.abs(dx), Math.abs(dy)); - final float velocity = maxAbsDelta / dt; - return velocity > mFlingThresholdPx; - } - - @Override - public boolean onTouch(final View view, final MotionEvent motionEvent) { - switch (motionEvent.getAction()) { - case MotionEvent.ACTION_UP: { - if (!mMoved || mDownEvent == null) { - return false; - } - final float dx = motionEvent.getRawX() - mDownEvent.getRawX(); - final float dy = motionEvent.getRawY() - mDownEvent.getRawY(); - - final float dt = - (motionEvent.getEventTime() - mDownEvent.getEventTime()) / 1000.0f; - final float yVelocity = dy / dt; - - boolean handled = false; - - // Vertical swipe occurred if the direction is as least mostly in the y - // component and has the required velocity (px/sec) - if ((dx == 0 || (Math.abs(dy) / Math.abs(dx)) > DIRECTION_RATIO) && - Math.abs(yVelocity) > mFlingThresholdPx) { - if (yVelocity < 0 && mExpanded) { - setFullScreenView(true, true); - handled = true; - } else if (yVelocity > 0) { - if (mFullScreen && yVelocity < mBigFlingThresholdPx) { - setFullScreenView(false, true); - } else { - setExpanded(false, true, PAGE_NOT_SET); - } - handled = true; - } - } - - if (!handled) { - // If they didn't swipe enough, animate back to resting state - setDesiredHeight(getDesiredHeight(), true); - } - resetState(); - break; - } - case MotionEvent.ACTION_DOWN: { - mDownHeight = getHeight(); - mDownEvent = MotionEvent.obtain(motionEvent); - // If we are here and care about the return value (i.e. this is not called - // from onInterceptTouchEvent), then presumably no children view in the panel - // handles the down event. We'd like to handle future ACTION_MOVE events, so - // always claim ownership on this event so it doesn't fall through and gets - // cancelled by the framework. - return true; - } - case MotionEvent.ACTION_MOVE: { - if (mDownEvent == null) { - return mMoved; - } - - final float dx = mDownEvent.getRawX() - motionEvent.getRawX(); - final float dy = mDownEvent.getRawY() - motionEvent.getRawY(); - // Don't act if the move is mostly horizontal - if (Math.abs(dy) > mTouchSlop && - (Math.abs(dy) / Math.abs(dx)) > DIRECTION_RATIO) { - setDesiredHeight((int) (mDownHeight + dy), false); - mMoved = true; - if (dy < -mTouchSlop) { - mMovedDown = true; - } - } - return mMoved; - } - - } - return mMoved; - } - - private void resetState() { - mDownEvent = null; - mDownHeight = -1; - mMoved = false; - mMovedDown = false; - mCanChildViewSwipeDown = false; - updateViewPager(); - } - } -} diff --git a/src/com/android/messaging/ui/mediapicker/MmsVideoRecorder.java b/src/com/android/messaging/ui/mediapicker/MmsVideoRecorder.java deleted file mode 100644 index 89241b74..00000000 --- a/src/com/android/messaging/ui/mediapicker/MmsVideoRecorder.java +++ /dev/null @@ -1,142 +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.mediapicker; - -import android.hardware.Camera; -import android.media.CamcorderProfile; -import android.media.MediaRecorder; -import android.net.Uri; -import android.os.ParcelFileDescriptor; - -import com.android.messaging.Factory; -import com.android.messaging.datamodel.MediaScratchFileProvider; -import com.android.messaging.util.ContentType; -import com.android.messaging.util.SafeAsyncTask; - -import java.io.FileNotFoundException; -import java.io.IOException; - -class MmsVideoRecorder extends MediaRecorder { - private static final float VIDEO_OVERSHOOT_SLOP = .85F; - - private static final int BITS_PER_BYTE = 8; - - // We think user will expect to be able to record videos at least this long - private static final long MIN_DURATION_LIMIT_SECONDS = 25; - - /** The uri where video is being recorded to */ - private Uri mTempVideoUri; - - private ParcelFileDescriptor mVideoFD; - - /** The settings used for video recording */ - private final CamcorderProfile mCamcorderProfile; - - public MmsVideoRecorder(final Camera camera, final int cameraIndex, final int orientation, - final int maxMessageSize) - throws FileNotFoundException { - mCamcorderProfile = - CamcorderProfile.get(cameraIndex, CamcorderProfile.QUALITY_LOW); - mTempVideoUri = MediaScratchFileProvider.buildMediaScratchSpaceUri( - ContentType.getExtension(getContentType())); - - // The video recorder can sometimes return a file that's larger than the max we - // say we can handle. Try to handle that overshoot by specifying an 85% limit. - final long sizeLimit = (long) (maxMessageSize * VIDEO_OVERSHOOT_SLOP); - - // The QUALITY_LOW profile might not be low enough to allow for video of a reasonable - // minimum duration. Adjust a/v bitrates to allow at least MIN_DURATION_LIMIT video - // to be recorded. - int audioBitRate = mCamcorderProfile.audioBitRate; - int videoBitRate = mCamcorderProfile.videoBitRate; - final double initialDurationLimit = sizeLimit * BITS_PER_BYTE - / (double) (audioBitRate + videoBitRate); - if (initialDurationLimit < MIN_DURATION_LIMIT_SECONDS) { - // Reduce the suggested bitrates. These bitrates are only requests, if implementation - // can't actually hit these goals it will still record video at higher rate and stop when - // it hits the size limit. - final double bitRateAdjustmentFactor = initialDurationLimit / MIN_DURATION_LIMIT_SECONDS; - audioBitRate *= bitRateAdjustmentFactor; - videoBitRate *= bitRateAdjustmentFactor; - } - - setCamera(camera); - setOrientationHint(orientation); - setAudioSource(MediaRecorder.AudioSource.CAMCORDER); - setVideoSource(MediaRecorder.VideoSource.CAMERA); - setOutputFormat(mCamcorderProfile.fileFormat); - mVideoFD = Factory.get().getApplicationContext().getContentResolver() - .openFileDescriptor(mTempVideoUri, "w"); - setOutputFile(mVideoFD.getFileDescriptor()); - - // Copy settings from CamcorderProfile to MediaRecorder - setAudioEncodingBitRate(audioBitRate); - setAudioChannels(mCamcorderProfile.audioChannels); - setAudioEncoder(mCamcorderProfile.audioCodec); - setAudioSamplingRate(mCamcorderProfile.audioSampleRate); - setVideoEncodingBitRate(videoBitRate); - setVideoEncoder(mCamcorderProfile.videoCodec); - setVideoFrameRate(mCamcorderProfile.videoFrameRate); - setVideoSize( - mCamcorderProfile.videoFrameWidth, mCamcorderProfile.videoFrameHeight); - setMaxFileSize(sizeLimit); - } - - Uri getVideoUri() { - return mTempVideoUri; - } - - int getVideoWidth() { - return mCamcorderProfile.videoFrameWidth; - } - - int getVideoHeight() { - return mCamcorderProfile.videoFrameHeight; - } - - void cleanupTempFile() { - final Uri tempUri = mTempVideoUri; - SafeAsyncTask.executeOnThreadPool(new Runnable() { - @Override - public void run() { - Factory.get().getApplicationContext().getContentResolver().delete( - tempUri, null, null); - } - }); - mTempVideoUri = null; - } - - String getContentType() { - if (mCamcorderProfile.fileFormat == OutputFormat.MPEG_4) { - return ContentType.VIDEO_MP4; - } else { - // 3GPP is the only other video format with a constant in OutputFormat - return ContentType.VIDEO_3GPP; - } - } - - public void closeVideoFileDescriptor() { - if (mVideoFD != null) { - try { - mVideoFD.close(); - } catch (IOException e) { - // Ignore - } - mVideoFD = null; - } - } -} diff --git a/src/com/android/messaging/ui/mediapicker/SoftwareCameraPreview.java b/src/com/android/messaging/ui/mediapicker/SoftwareCameraPreview.java deleted file mode 100644 index 5dc31855..00000000 --- a/src/com/android/messaging/ui/mediapicker/SoftwareCameraPreview.java +++ /dev/null @@ -1,114 +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.mediapicker; - -import android.content.Context; -import android.hardware.Camera; -import android.os.Parcelable; -import android.view.SurfaceHolder; -import android.view.SurfaceView; -import android.view.View; - -import java.io.IOException; - -/** - * A software rendered preview surface for the camera. This renders slower and causes more jank, so - * HardwareCameraPreview is preferred if possible. - * - * There is a significant amount of duplication between HardwareCameraPreview and - * SoftwareCameraPreview which we can't easily share due to a lack of multiple inheritance, The - * implementations of the shared methods are delegated to CameraPreview - */ -public class SoftwareCameraPreview extends SurfaceView implements CameraPreview.CameraPreviewHost { - private final CameraPreview mPreview; - - public SoftwareCameraPreview(final Context context) { - super(context); - mPreview = new CameraPreview(this); - getHolder().addCallback(new SurfaceHolder.Callback() { - @Override - public void surfaceCreated(final SurfaceHolder surfaceHolder) { - CameraManager.get().setSurface(mPreview); - } - - @Override - public void surfaceChanged(final SurfaceHolder surfaceHolder, final int format, final int width, - final int height) { - CameraManager.get().setSurface(mPreview); - } - - @Override - public void surfaceDestroyed(final SurfaceHolder surfaceHolder) { - CameraManager.get().setSurface(null); - } - }); - } - - - @Override - protected void onVisibilityChanged(final View changedView, final int visibility) { - super.onVisibilityChanged(changedView, visibility); - mPreview.onVisibilityChanged(visibility); - } - - @Override - protected void onDetachedFromWindow() { - super.onDetachedFromWindow(); - mPreview.onDetachedFromWindow(); - } - - @Override - protected void onAttachedToWindow() { - super.onAttachedToWindow(); - mPreview.onAttachedToWindow(); - } - - @Override - protected void onRestoreInstanceState(final Parcelable state) { - super.onRestoreInstanceState(state); - mPreview.onRestoreInstanceState(); - } - - @Override - protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { - widthMeasureSpec = mPreview.getWidthMeasureSpec(widthMeasureSpec, heightMeasureSpec); - heightMeasureSpec = mPreview.getHeightMeasureSpec(widthMeasureSpec, heightMeasureSpec); - super.onMeasure(widthMeasureSpec, heightMeasureSpec); - } - - @Override - public View getView() { - return this; - } - - @Override - public boolean isValid() { - return getHolder() != null; - } - - @Override - public void startPreview(final Camera camera) throws IOException { - camera.setPreviewDisplay(getHolder()); - } - - @Override - public void onCameraPermissionGranted() { - mPreview.onCameraPermissionGranted(); - } -} - - diff --git a/src/com/android/messaging/ui/mediapicker/SoundLevels.java b/src/com/android/messaging/ui/mediapicker/SoundLevels.java deleted file mode 100644 index 6f4dca6a..00000000 --- a/src/com/android/messaging/ui/mediapicker/SoundLevels.java +++ /dev/null @@ -1,212 +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.mediapicker; - -import android.animation.ObjectAnimator; -import android.animation.TimeAnimator; -import android.animation.TimeAnimator.TimeListener; -import android.content.Context; -import android.content.res.TypedArray; -import android.graphics.Canvas; -import android.graphics.Color; -import android.graphics.Paint; -import android.graphics.Paint.Style; -import android.util.AttributeSet; -import android.util.Log; -import android.view.View; -import android.view.accessibility.AccessibilityNodeInfo; - -import com.android.messaging.R; -import com.android.messaging.util.LogUtil; - -/** - * This view draws circular sound levels. By default the sound levels are black, unless - * otherwise defined via {@link #mPrimaryLevelPaint}. - */ -public class SoundLevels extends View { - private static final String TAG = LogUtil.BUGLE_TAG; - private static final boolean DEBUG = false; - - private boolean mCenterDefined; - private int mCenterX; - private int mCenterY; - - // Paint for the main level meter, most closely follows the mic. - private final Paint mPrimaryLevelPaint; - - // The minimum size of the levels as a percentage of the max, that is the size when volume is 0. - private final float mMinimumLevel; - - // The minimum size of the levels, that is the size when volume is 0. - private final float mMinimumLevelSize; - - // The maximum size of the levels, that is the size when volume is 100. - private final float mMaximumLevelSize; - - // Generates clock ticks for the animation using the global animation loop. - private final TimeAnimator mSpeechLevelsAnimator; - - private float mCurrentVolume; - - // Indicates whether we should be animating the sound level. - private boolean mIsEnabled; - - // Input level is pulled from here. - private AudioLevelSource mLevelSource; - - public SoundLevels(final Context context) { - this(context, null); - } - - public SoundLevels(final Context context, final AttributeSet attrs) { - this(context, attrs, 0); - } - - public SoundLevels(final Context context, final AttributeSet attrs, final int defStyle) { - super(context, attrs, defStyle); - - // Safe source, replaced with system one when attached. - mLevelSource = new AudioLevelSource(); - mLevelSource.setSpeechLevel(0); - - final TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.SoundLevels, - defStyle, 0); - - mMaximumLevelSize = a.getDimensionPixelOffset( - R.styleable.SoundLevels_maxLevelRadius, 0); - mMinimumLevelSize = a.getDimensionPixelOffset( - R.styleable.SoundLevels_minLevelRadius, 0); - mMinimumLevel = mMinimumLevelSize / mMaximumLevelSize; - - mPrimaryLevelPaint = new Paint(); - mPrimaryLevelPaint.setColor( - a.getColor(R.styleable.SoundLevels_primaryColor, Color.BLACK)); - mPrimaryLevelPaint.setFlags(Paint.ANTI_ALIAS_FLAG); - - a.recycle(); - - // This animator generates ticks that invalidate the - // view so that the animation is synced with the global animation loop. - // TODO: We could probably remove this in favor of using postInvalidateOnAnimation - // which might improve things further. - mSpeechLevelsAnimator = new TimeAnimator(); - mSpeechLevelsAnimator.setRepeatCount(ObjectAnimator.INFINITE); - mSpeechLevelsAnimator.setTimeListener(new TimeListener() { - @Override - public void onTimeUpdate(final TimeAnimator animation, final long totalTime, - final long deltaTime) { - invalidate(); - } - }); - } - - @Override - protected void onDraw(final Canvas canvas) { - if (!mIsEnabled) { - return; - } - - if (!mCenterDefined) { - // One time computation here, because we can't rely on getWidth() to be computed at - // constructor time or in onFinishInflate :(. - mCenterX = getWidth() / 2; - mCenterY = getWidth() / 2; - mCenterDefined = true; - } - - final int level = mLevelSource.getSpeechLevel(); - // Either ease towards the target level, or decay away from it depending on whether - // its higher or lower than the current. - if (level > mCurrentVolume) { - mCurrentVolume = mCurrentVolume + ((level - mCurrentVolume) / 4); - } else { - mCurrentVolume = mCurrentVolume * 0.95f; - } - - final float radius = mMinimumLevel + (1f - mMinimumLevel) * mCurrentVolume / 100; - mPrimaryLevelPaint.setStyle(Style.FILL); - canvas.drawCircle(mCenterX, mCenterY, radius * mMaximumLevelSize, mPrimaryLevelPaint); - } - - public void setLevelSource(final AudioLevelSource source) { - if (DEBUG) { - Log.d(TAG, "Speech source set."); - } - mLevelSource = source; - } - - private void startSpeechLevelsAnimator() { - if (DEBUG) { - Log.d(TAG, "startAnimator()"); - } - if (!mSpeechLevelsAnimator.isStarted()) { - mSpeechLevelsAnimator.start(); - } - } - - private void stopSpeechLevelsAnimator() { - if (DEBUG) { - Log.d(TAG, "stopAnimator()"); - } - if (mSpeechLevelsAnimator.isStarted()) { - mSpeechLevelsAnimator.end(); - } - } - - @Override - protected void onDetachedFromWindow() { - super.onDetachedFromWindow(); - stopSpeechLevelsAnimator(); - } - - @Override - public void setEnabled(final boolean enabled) { - if (enabled == mIsEnabled) { - return; - } - if (DEBUG) { - Log.d("TAG", "setEnabled: " + enabled); - } - super.setEnabled(enabled); - mIsEnabled = enabled; - setKeepScreenOn(enabled); - updateSpeechLevelsAnimatorState(); - } - - private void updateSpeechLevelsAnimatorState() { - if (mIsEnabled) { - startSpeechLevelsAnimator(); - } else { - stopSpeechLevelsAnimator(); - } - } - - /** - * This is required to make the View findable by uiautomator - */ - @Override - public void onInitializeAccessibilityNodeInfo(final AccessibilityNodeInfo info) { - super.onInitializeAccessibilityNodeInfo(info); - info.setClassName(SoundLevels.class.getCanonicalName()); - } - - /** - * Set the alpha level of the sound circles. - */ - public void setPrimaryColorAlpha(final int alpha) { - mPrimaryLevelPaint.setAlpha(alpha); - } -} diff --git a/src/com/android/messaging/ui/mediapicker/camerafocus/FocusIndicator.java b/src/com/android/messaging/ui/mediapicker/camerafocus/FocusIndicator.java deleted file mode 100644 index 92ed3c1c..00000000 --- a/src/com/android/messaging/ui/mediapicker/camerafocus/FocusIndicator.java +++ /dev/null @@ -1,24 +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.mediapicker.camerafocus; - -public interface FocusIndicator { - public void showStart(); - public void showSuccess(boolean timeout); - public void showFail(boolean timeout); - public void clear(); -} \ No newline at end of file diff --git a/src/com/android/messaging/ui/mediapicker/camerafocus/FocusOverlayManager.java b/src/com/android/messaging/ui/mediapicker/camerafocus/FocusOverlayManager.java deleted file mode 100644 index e620fc27..00000000 --- a/src/com/android/messaging/ui/mediapicker/camerafocus/FocusOverlayManager.java +++ /dev/null @@ -1,589 +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.mediapicker.camerafocus; - -import android.graphics.Matrix; -import android.graphics.Rect; -import android.graphics.RectF; -import android.hardware.Camera.Area; -import android.hardware.Camera.Parameters; -import android.os.Handler; -import android.os.Looper; -import android.os.Message; - -import com.android.messaging.util.Assert; -import com.android.messaging.util.LogUtil; - -import java.util.ArrayList; -import java.util.List; - -/* A class that handles everything about focus in still picture mode. - * This also handles the metering area because it is the same as focus area. - * - * The test cases: - * (1) The camera has continuous autofocus. Move the camera. Take a picture when - * CAF is not in progress. - * (2) The camera has continuous autofocus. Move the camera. Take a picture when - * CAF is in progress. - * (3) The camera has face detection. Point the camera at some faces. Hold the - * shutter. Release to take a picture. - * (4) The camera has face detection. Point the camera at some faces. Single tap - * the shutter to take a picture. - * (5) The camera has autofocus. Single tap the shutter to take a picture. - * (6) The camera has autofocus. Hold the shutter. Release to take a picture. - * (7) The camera has no autofocus. Single tap the shutter and take a picture. - * (8) The camera has autofocus and supports focus area. Touch the screen to - * trigger autofocus. Take a picture. - * (9) The camera has autofocus and supports focus area. Touch the screen to - * trigger autofocus. Wait until it times out. - * (10) The camera has no autofocus and supports metering area. Touch the screen - * to change metering area. - */ -public class FocusOverlayManager { - private static final String TAG = LogUtil.BUGLE_TAG; - private static final String TRUE = "true"; - private static final String AUTO_EXPOSURE_LOCK_SUPPORTED = "auto-exposure-lock-supported"; - private static final String AUTO_WHITE_BALANCE_LOCK_SUPPORTED = - "auto-whitebalance-lock-supported"; - - private static final int RESET_TOUCH_FOCUS = 0; - private static final int RESET_TOUCH_FOCUS_DELAY = 3000; - - private int mState = STATE_IDLE; - private static final int STATE_IDLE = 0; // Focus is not active. - private static final int STATE_FOCUSING = 1; // Focus is in progress. - // Focus is in progress and the camera should take a picture after focus finishes. - private static final int STATE_FOCUSING_SNAP_ON_FINISH = 2; - private static final int STATE_SUCCESS = 3; // Focus finishes and succeeds. - private static final int STATE_FAIL = 4; // Focus finishes and fails. - - private boolean mInitialized; - private boolean mFocusAreaSupported; - private boolean mMeteringAreaSupported; - private boolean mLockAeAwbNeeded; - private boolean mAeAwbLock; - private Matrix mMatrix; - - private PieRenderer mPieRenderer; - - private int mPreviewWidth; // The width of the preview frame layout. - private int mPreviewHeight; // The height of the preview frame layout. - private boolean mMirror; // true if the camera is front-facing. - private int mDisplayOrientation; - private List mFocusArea; // focus area in driver format - private List mMeteringArea; // metering area in driver format - private String mFocusMode; - private String mOverrideFocusMode; - private Parameters mParameters; - private Handler mHandler; - Listener mListener; - - public interface Listener { - public void autoFocus(); - public void cancelAutoFocus(); - public boolean capture(); - public void setFocusParameters(); - } - - private class MainHandler extends Handler { - public MainHandler(Looper looper) { - super(looper); - } - - @Override - public void handleMessage(Message msg) { - switch (msg.what) { - case RESET_TOUCH_FOCUS: { - cancelAutoFocus(); - break; - } - } - } - } - - public FocusOverlayManager(Listener listener, Looper looper) { - mHandler = new MainHandler(looper); - mMatrix = new Matrix(); - mListener = listener; - } - - public void setFocusRenderer(PieRenderer renderer) { - mPieRenderer = renderer; - mInitialized = (mMatrix != null); - } - - public void setParameters(Parameters parameters) { - // parameters can only be null when onConfigurationChanged is called - // before camera is open. We will just return in this case, because - // parameters will be set again later with the right parameters after - // camera is open. - if (parameters == null) { - return; - } - mParameters = parameters; - mFocusAreaSupported = isFocusAreaSupported(parameters); - mMeteringAreaSupported = isMeteringAreaSupported(parameters); - mLockAeAwbNeeded = (isAutoExposureLockSupported(mParameters) || - isAutoWhiteBalanceLockSupported(mParameters)); - } - - public void setPreviewSize(int previewWidth, int previewHeight) { - if (mPreviewWidth != previewWidth || mPreviewHeight != previewHeight) { - mPreviewWidth = previewWidth; - mPreviewHeight = previewHeight; - setMatrix(); - } - } - - public void setMirror(boolean mirror) { - mMirror = mirror; - setMatrix(); - } - - public void setDisplayOrientation(int displayOrientation) { - mDisplayOrientation = displayOrientation; - setMatrix(); - } - - private void setMatrix() { - if (mPreviewWidth != 0 && mPreviewHeight != 0) { - Matrix matrix = new Matrix(); - prepareMatrix(matrix, mMirror, mDisplayOrientation, - mPreviewWidth, mPreviewHeight); - // In face detection, the matrix converts the driver coordinates to UI - // coordinates. In tap focus, the inverted matrix converts the UI - // coordinates to driver coordinates. - matrix.invert(mMatrix); - mInitialized = (mPieRenderer != null); - } - } - - private void lockAeAwbIfNeeded() { - if (mLockAeAwbNeeded && !mAeAwbLock) { - mAeAwbLock = true; - mListener.setFocusParameters(); - } - } - - private void unlockAeAwbIfNeeded() { - if (mLockAeAwbNeeded && mAeAwbLock && (mState != STATE_FOCUSING_SNAP_ON_FINISH)) { - mAeAwbLock = false; - mListener.setFocusParameters(); - } - } - - public void onShutterDown() { - if (!mInitialized) { - return; - } - - boolean autoFocusCalled = false; - if (needAutoFocusCall()) { - // Do not focus if touch focus has been triggered. - if (mState != STATE_SUCCESS && mState != STATE_FAIL) { - autoFocus(); - autoFocusCalled = true; - } - } - - if (!autoFocusCalled) { - lockAeAwbIfNeeded(); - } - } - - public void onShutterUp() { - if (!mInitialized) { - return; - } - - if (needAutoFocusCall()) { - // User releases half-pressed focus key. - if (mState == STATE_FOCUSING || mState == STATE_SUCCESS - || mState == STATE_FAIL) { - cancelAutoFocus(); - } - } - - // Unlock AE and AWB after cancelAutoFocus. Camera API does not - // guarantee setParameters can be called during autofocus. - unlockAeAwbIfNeeded(); - } - - public void doSnap() { - if (!mInitialized) { - return; - } - - // If the user has half-pressed the shutter and focus is completed, we - // can take the photo right away. If the focus mode is infinity, we can - // also take the photo. - if (!needAutoFocusCall() || (mState == STATE_SUCCESS || mState == STATE_FAIL)) { - capture(); - } else if (mState == STATE_FOCUSING) { - // Half pressing the shutter (i.e. the focus button event) will - // already have requested AF for us, so just request capture on - // focus here. - mState = STATE_FOCUSING_SNAP_ON_FINISH; - } else if (mState == STATE_IDLE) { - // We didn't do focus. This can happen if the user press focus key - // while the snapshot is still in progress. The user probably wants - // the next snapshot as soon as possible, so we just do a snapshot - // without focusing again. - capture(); - } - } - - public void onAutoFocus(boolean focused, boolean shutterButtonPressed) { - if (mState == STATE_FOCUSING_SNAP_ON_FINISH) { - // Take the picture no matter focus succeeds or fails. No need - // to play the AF sound if we're about to play the shutter - // sound. - if (focused) { - mState = STATE_SUCCESS; - } else { - mState = STATE_FAIL; - } - updateFocusUI(); - capture(); - } else if (mState == STATE_FOCUSING) { - // This happens when (1) user is half-pressing the focus key or - // (2) touch focus is triggered. Play the focus tone. Do not - // take the picture now. - if (focused) { - mState = STATE_SUCCESS; - } else { - mState = STATE_FAIL; - } - updateFocusUI(); - // If this is triggered by touch focus, cancel focus after a - // while. - if (mFocusArea != null) { - mHandler.sendEmptyMessageDelayed(RESET_TOUCH_FOCUS, RESET_TOUCH_FOCUS_DELAY); - } - if (shutterButtonPressed) { - // Lock AE & AWB so users can half-press shutter and recompose. - lockAeAwbIfNeeded(); - } - } else if (mState == STATE_IDLE) { - // User has released the focus key before focus completes. - // Do nothing. - } - } - - public void onAutoFocusMoving(boolean moving) { - if (!mInitialized) { - return; - } - - // Ignore if we have requested autofocus. This method only handles - // continuous autofocus. - if (mState != STATE_IDLE) { - return; - } - - if (moving) { - mPieRenderer.showStart(); - } else { - mPieRenderer.showSuccess(true); - } - } - - private void initializeFocusAreas(int focusWidth, int focusHeight, - int x, int y, int previewWidth, int previewHeight) { - if (mFocusArea == null) { - mFocusArea = new ArrayList(); - mFocusArea.add(new Area(new Rect(), 1)); - } - - // Convert the coordinates to driver format. - calculateTapArea(focusWidth, focusHeight, 1f, x, y, previewWidth, previewHeight, - ((Area) mFocusArea.get(0)).rect); - } - - private void initializeMeteringAreas(int focusWidth, int focusHeight, - int x, int y, int previewWidth, int previewHeight) { - if (mMeteringArea == null) { - mMeteringArea = new ArrayList(); - mMeteringArea.add(new Area(new Rect(), 1)); - } - - // Convert the coordinates to driver format. - // AE area is bigger because exposure is sensitive and - // easy to over- or underexposure if area is too small. - calculateTapArea(focusWidth, focusHeight, 1.5f, x, y, previewWidth, previewHeight, - ((Area) mMeteringArea.get(0)).rect); - } - - public void onSingleTapUp(int x, int y) { - if (!mInitialized || mState == STATE_FOCUSING_SNAP_ON_FINISH) { - return; - } - - // Let users be able to cancel previous touch focus. - if ((mFocusArea != null) && (mState == STATE_FOCUSING || - mState == STATE_SUCCESS || mState == STATE_FAIL)) { - cancelAutoFocus(); - } - // Initialize variables. - int focusWidth = mPieRenderer.getSize(); - int focusHeight = mPieRenderer.getSize(); - if (focusWidth == 0 || mPieRenderer.getWidth() == 0 || mPieRenderer.getHeight() == 0) { - return; - } - int previewWidth = mPreviewWidth; - int previewHeight = mPreviewHeight; - // Initialize mFocusArea. - if (mFocusAreaSupported) { - initializeFocusAreas(focusWidth, focusHeight, x, y, previewWidth, previewHeight); - } - // Initialize mMeteringArea. - if (mMeteringAreaSupported) { - initializeMeteringAreas(focusWidth, focusHeight, x, y, previewWidth, previewHeight); - } - - // Use margin to set the focus indicator to the touched area. - mPieRenderer.setFocus(x, y); - - // Set the focus area and metering area. - mListener.setFocusParameters(); - if (mFocusAreaSupported) { - autoFocus(); - } else { // Just show the indicator in all other cases. - updateFocusUI(); - // Reset the metering area in 3 seconds. - mHandler.removeMessages(RESET_TOUCH_FOCUS); - mHandler.sendEmptyMessageDelayed(RESET_TOUCH_FOCUS, RESET_TOUCH_FOCUS_DELAY); - } - } - - public void onPreviewStarted() { - mState = STATE_IDLE; - } - - public void onPreviewStopped() { - // If auto focus was in progress, it would have been stopped. - mState = STATE_IDLE; - resetTouchFocus(); - updateFocusUI(); - } - - public void onCameraReleased() { - onPreviewStopped(); - } - - private void autoFocus() { - LogUtil.v(TAG, "Start autofocus."); - mListener.autoFocus(); - mState = STATE_FOCUSING; - updateFocusUI(); - mHandler.removeMessages(RESET_TOUCH_FOCUS); - } - - private void cancelAutoFocus() { - LogUtil.v(TAG, "Cancel autofocus."); - - // Reset the tap area before calling mListener.cancelAutofocus. - // Otherwise, focus mode stays at auto and the tap area passed to the - // driver is not reset. - resetTouchFocus(); - mListener.cancelAutoFocus(); - mState = STATE_IDLE; - updateFocusUI(); - mHandler.removeMessages(RESET_TOUCH_FOCUS); - } - - private void capture() { - if (mListener.capture()) { - mState = STATE_IDLE; - mHandler.removeMessages(RESET_TOUCH_FOCUS); - } - } - - public String getFocusMode() { - if (mOverrideFocusMode != null) { - return mOverrideFocusMode; - } - List supportedFocusModes = mParameters.getSupportedFocusModes(); - - if (mFocusAreaSupported && mFocusArea != null) { - // Always use autofocus in tap-to-focus. - mFocusMode = Parameters.FOCUS_MODE_AUTO; - } else { - mFocusMode = Parameters.FOCUS_MODE_CONTINUOUS_PICTURE; - } - - if (!isSupported(mFocusMode, supportedFocusModes)) { - // For some reasons, the driver does not support the current - // focus mode. Fall back to auto. - if (isSupported(Parameters.FOCUS_MODE_AUTO, - mParameters.getSupportedFocusModes())) { - mFocusMode = Parameters.FOCUS_MODE_AUTO; - } else { - mFocusMode = mParameters.getFocusMode(); - } - } - return mFocusMode; - } - - public List getFocusAreas() { - return mFocusArea; - } - - public List getMeteringAreas() { - return mMeteringArea; - } - - public void updateFocusUI() { - if (!mInitialized) { - return; - } - FocusIndicator focusIndicator = mPieRenderer; - - if (mState == STATE_IDLE) { - if (mFocusArea == null) { - focusIndicator.clear(); - } else { - // Users touch on the preview and the indicator represents the - // metering area. Either focus area is not supported or - // autoFocus call is not required. - focusIndicator.showStart(); - } - } else if (mState == STATE_FOCUSING || mState == STATE_FOCUSING_SNAP_ON_FINISH) { - focusIndicator.showStart(); - } else { - if (Parameters.FOCUS_MODE_CONTINUOUS_PICTURE.equals(mFocusMode)) { - // TODO: check HAL behavior and decide if this can be removed. - focusIndicator.showSuccess(false); - } else if (mState == STATE_SUCCESS) { - focusIndicator.showSuccess(false); - } else if (mState == STATE_FAIL) { - focusIndicator.showFail(false); - } - } - } - - public void resetTouchFocus() { - if (!mInitialized) { - return; - } - - // Put focus indicator to the center. clear reset position - mPieRenderer.clear(); - - mFocusArea = null; - mMeteringArea = null; - } - - private void calculateTapArea(int focusWidth, int focusHeight, float areaMultiple, - int x, int y, int previewWidth, int previewHeight, Rect rect) { - int areaWidth = (int) (focusWidth * areaMultiple); - int areaHeight = (int) (focusHeight * areaMultiple); - int left = clamp(x - areaWidth / 2, 0, previewWidth - areaWidth); - int top = clamp(y - areaHeight / 2, 0, previewHeight - areaHeight); - - RectF rectF = new RectF(left, top, left + areaWidth, top + areaHeight); - mMatrix.mapRect(rectF); - rectFToRect(rectF, rect); - } - - /* package */ int getFocusState() { - return mState; - } - - public boolean isFocusCompleted() { - return mState == STATE_SUCCESS || mState == STATE_FAIL; - } - - public boolean isFocusingSnapOnFinish() { - return mState == STATE_FOCUSING_SNAP_ON_FINISH; - } - - public void removeMessages() { - mHandler.removeMessages(RESET_TOUCH_FOCUS); - } - - public void overrideFocusMode(String focusMode) { - mOverrideFocusMode = focusMode; - } - - public void setAeAwbLock(boolean lock) { - mAeAwbLock = lock; - } - - public boolean getAeAwbLock() { - return mAeAwbLock; - } - - private boolean needAutoFocusCall() { - String focusMode = getFocusMode(); - return !(focusMode.equals(Parameters.FOCUS_MODE_INFINITY) - || focusMode.equals(Parameters.FOCUS_MODE_FIXED) - || focusMode.equals(Parameters.FOCUS_MODE_EDOF)); - } - - public static boolean isAutoExposureLockSupported(Parameters params) { - return TRUE.equals(params.get(AUTO_EXPOSURE_LOCK_SUPPORTED)); - } - - public static boolean isAutoWhiteBalanceLockSupported(Parameters params) { - return TRUE.equals(params.get(AUTO_WHITE_BALANCE_LOCK_SUPPORTED)); - } - - public static boolean isSupported(String value, List supported) { - return supported != null && supported.indexOf(value) >= 0; - } - - public static boolean isMeteringAreaSupported(Parameters params) { - return params.getMaxNumMeteringAreas() > 0; - } - - public static boolean isFocusAreaSupported(Parameters params) { - return (params.getMaxNumFocusAreas() > 0 - && isSupported(Parameters.FOCUS_MODE_AUTO, - params.getSupportedFocusModes())); - } - - public static void prepareMatrix(Matrix matrix, boolean mirror, int displayOrientation, - int viewWidth, int viewHeight) { - // Need mirror for front camera. - matrix.setScale(mirror ? -1 : 1, 1); - // This is the value for android.hardware.Camera.setDisplayOrientation. - matrix.postRotate(displayOrientation); - // Camera driver coordinates range from (-1000, -1000) to (1000, 1000). - // UI coordinates range from (0, 0) to (width, height). - matrix.postScale(viewWidth / 2000f, viewHeight / 2000f); - matrix.postTranslate(viewWidth / 2f, viewHeight / 2f); - } - - public static int clamp(int x, int min, int max) { - Assert.isTrue(max >= min); - if (x > max) { - return max; - } - if (x < min) { - return min; - } - return x; - } - - public static void rectFToRect(RectF rectF, Rect rect) { - rect.left = Math.round(rectF.left); - rect.top = Math.round(rectF.top); - rect.right = Math.round(rectF.right); - rect.bottom = Math.round(rectF.bottom); - } -} diff --git a/src/com/android/messaging/ui/mediapicker/camerafocus/OverlayRenderer.java b/src/com/android/messaging/ui/mediapicker/camerafocus/OverlayRenderer.java deleted file mode 100644 index df6734f6..00000000 --- a/src/com/android/messaging/ui/mediapicker/camerafocus/OverlayRenderer.java +++ /dev/null @@ -1,95 +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.mediapicker.camerafocus; - -import android.content.Context; -import android.graphics.Canvas; -import android.view.MotionEvent; - -public abstract class OverlayRenderer implements RenderOverlay.Renderer { - - private static final String TAG = "CAM OverlayRenderer"; - protected RenderOverlay mOverlay; - - protected int mLeft, mTop, mRight, mBottom; - - protected boolean mVisible; - - public void setVisible(boolean vis) { - mVisible = vis; - update(); - } - - public boolean isVisible() { - return mVisible; - } - - // default does not handle touch - @Override - public boolean handlesTouch() { - return false; - } - - @Override - public boolean onTouchEvent(MotionEvent evt) { - return false; - } - - public abstract void onDraw(Canvas canvas); - - public void draw(Canvas canvas) { - if (mVisible) { - onDraw(canvas); - } - } - - @Override - public void setOverlay(RenderOverlay overlay) { - mOverlay = overlay; - } - - @Override - public void layout(int left, int top, int right, int bottom) { - mLeft = left; - mRight = right; - mTop = top; - mBottom = bottom; - } - - protected Context getContext() { - if (mOverlay != null) { - return mOverlay.getContext(); - } else { - return null; - } - } - - public int getWidth() { - return mRight - mLeft; - } - - public int getHeight() { - return mBottom - mTop; - } - - protected void update() { - if (mOverlay != null) { - mOverlay.update(); - } - } - -} \ No newline at end of file diff --git a/src/com/android/messaging/ui/mediapicker/camerafocus/PieItem.java b/src/com/android/messaging/ui/mediapicker/camerafocus/PieItem.java deleted file mode 100644 index c6028521..00000000 --- a/src/com/android/messaging/ui/mediapicker/camerafocus/PieItem.java +++ /dev/null @@ -1,202 +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.mediapicker.camerafocus; - -import android.content.Context; -import android.graphics.Canvas; -import android.graphics.Path; -import android.graphics.drawable.Drawable; - -import java.util.ArrayList; -import java.util.List; - -/** - * Pie menu item - */ -public class PieItem { - - public static interface OnClickListener { - void onClick(PieItem item); - } - - private Drawable mDrawable; - private int level; - private float mCenter; - private float start; - private float sweep; - private float animate; - private int inner; - private int outer; - private boolean mSelected; - private boolean mEnabled; - private List mItems; - private Path mPath; - private OnClickListener mOnClickListener; - private float mAlpha; - - // Gray out the view when disabled - private static final float ENABLED_ALPHA = 1; - private static final float DISABLED_ALPHA = (float) 0.3; - private boolean mChangeAlphaWhenDisabled = true; - - public PieItem(Drawable drawable, int level) { - mDrawable = drawable; - this.level = level; - setAlpha(1f); - mEnabled = true; - setAnimationAngle(getAnimationAngle()); - start = -1; - mCenter = -1; - } - - public boolean hasItems() { - return mItems != null; - } - - public List getItems() { - return mItems; - } - - public void addItem(PieItem item) { - if (mItems == null) { - mItems = new ArrayList(); - } - mItems.add(item); - } - - public void setPath(Path p) { - mPath = p; - } - - public Path getPath() { - return mPath; - } - - public void setChangeAlphaWhenDisabled (boolean enable) { - mChangeAlphaWhenDisabled = enable; - } - - public void setAlpha(float alpha) { - mAlpha = alpha; - mDrawable.setAlpha((int) (255 * alpha)); - } - - public void setAnimationAngle(float a) { - animate = a; - } - - public float getAnimationAngle() { - return animate; - } - - public void setEnabled(boolean enabled) { - mEnabled = enabled; - if (mChangeAlphaWhenDisabled) { - if (mEnabled) { - setAlpha(ENABLED_ALPHA); - } else { - setAlpha(DISABLED_ALPHA); - } - } - } - - public boolean isEnabled() { - return mEnabled; - } - - public void setSelected(boolean s) { - mSelected = s; - } - - public boolean isSelected() { - return mSelected; - } - - public int getLevel() { - return level; - } - - public void setGeometry(float st, float sw, int inside, int outside) { - start = st; - sweep = sw; - inner = inside; - outer = outside; - } - - public void setFixedSlice(float center, float sweep) { - mCenter = center; - this.sweep = sweep; - } - - public float getCenter() { - return mCenter; - } - - public float getStart() { - return start; - } - - public float getStartAngle() { - return start + animate; - } - - public float getSweep() { - return sweep; - } - - public int getInnerRadius() { - return inner; - } - - public int getOuterRadius() { - return outer; - } - - public void setOnClickListener(OnClickListener listener) { - mOnClickListener = listener; - } - - public void performClick() { - if (mOnClickListener != null) { - mOnClickListener.onClick(this); - } - } - - public int getIntrinsicWidth() { - return mDrawable.getIntrinsicWidth(); - } - - public int getIntrinsicHeight() { - return mDrawable.getIntrinsicHeight(); - } - - public void setBounds(int left, int top, int right, int bottom) { - mDrawable.setBounds(left, top, right, bottom); - } - - public void draw(Canvas canvas) { - mDrawable.draw(canvas); - } - - public void setImageResource(Context context, int resId) { - Drawable d = context.getResources().getDrawable(resId).mutate(); - d.setBounds(mDrawable.getBounds()); - mDrawable = d; - setAlpha(mAlpha); - } - -} \ No newline at end of file diff --git a/src/com/android/messaging/ui/mediapicker/camerafocus/PieRenderer.java b/src/com/android/messaging/ui/mediapicker/camerafocus/PieRenderer.java deleted file mode 100644 index ce8ca007..00000000 --- a/src/com/android/messaging/ui/mediapicker/camerafocus/PieRenderer.java +++ /dev/null @@ -1,825 +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.mediapicker.camerafocus; - -import android.animation.Animator; -import android.animation.AnimatorListenerAdapter; -import android.content.Context; -import android.content.res.Resources; -import android.graphics.Canvas; -import android.graphics.Color; -import android.graphics.Paint; -import android.graphics.Path; -import android.graphics.Point; -import android.graphics.PointF; -import android.graphics.RectF; -import android.os.Handler; -import android.os.Message; -import android.view.MotionEvent; -import android.view.ViewConfiguration; -import android.view.animation.Animation; -import android.view.animation.Animation.AnimationListener; -import android.view.animation.LinearInterpolator; -import android.view.animation.Transformation; -import com.android.messaging.R; - -import java.util.ArrayList; -import java.util.List; - -public class PieRenderer extends OverlayRenderer - implements FocusIndicator { - // Sometimes continuous autofocus starts and stops several times quickly. - // These states are used to make sure the animation is run for at least some - // time. - private volatile int mState; - private ScaleAnimation mAnimation = new ScaleAnimation(); - private static final int STATE_IDLE = 0; - private static final int STATE_FOCUSING = 1; - private static final int STATE_FINISHING = 2; - private static final int STATE_PIE = 8; - - private Runnable mDisappear = new Disappear(); - private Animation.AnimationListener mEndAction = new EndAction(); - private static final int SCALING_UP_TIME = 600; - private static final int SCALING_DOWN_TIME = 100; - private static final int DISAPPEAR_TIMEOUT = 200; - private static final int DIAL_HORIZONTAL = 157; - - private static final long PIE_FADE_IN_DURATION = 200; - private static final long PIE_XFADE_DURATION = 200; - private static final long PIE_SELECT_FADE_DURATION = 300; - - private static final int MSG_OPEN = 0; - private static final int MSG_CLOSE = 1; - private static final float PIE_SWEEP = (float) (Math.PI * 2 / 3); - // geometry - private Point mCenter; - private int mRadius; - private int mRadiusInc; - - // the detection if touch is inside a slice is offset - // inbounds by this amount to allow the selection to show before the - // finger covers it - private int mTouchOffset; - - private List mItems; - - private PieItem mOpenItem; - - private Paint mSelectedPaint; - private Paint mSubPaint; - - // touch handling - private PieItem mCurrentItem; - - private Paint mFocusPaint; - private int mSuccessColor; - private int mFailColor; - private int mCircleSize; - private int mFocusX; - private int mFocusY; - private int mCenterX; - private int mCenterY; - - private int mDialAngle; - private RectF mCircle; - private RectF mDial; - private Point mPoint1; - private Point mPoint2; - private int mStartAnimationAngle; - private boolean mFocused; - private int mInnerOffset; - private int mOuterStroke; - private int mInnerStroke; - private boolean mTapMode; - private boolean mBlockFocus; - private int mTouchSlopSquared; - private Point mDown; - private boolean mOpening; - private LinearAnimation mXFade; - private LinearAnimation mFadeIn; - private volatile boolean mFocusCancelled; - - private Handler mHandler = new Handler() { - public void handleMessage(Message msg) { - switch(msg.what) { - case MSG_OPEN: - if (mListener != null) { - mListener.onPieOpened(mCenter.x, mCenter.y); - } - break; - case MSG_CLOSE: - if (mListener != null) { - mListener.onPieClosed(); - } - break; - } - } - }; - - private PieListener mListener; - - public static interface PieListener { - public void onPieOpened(int centerX, int centerY); - public void onPieClosed(); - } - - public void setPieListener(PieListener pl) { - mListener = pl; - } - - public PieRenderer(Context context) { - init(context); - } - - private void init(Context ctx) { - setVisible(false); - mItems = new ArrayList(); - Resources res = ctx.getResources(); - mRadius = (int) res.getDimensionPixelSize(R.dimen.pie_radius_start); - mCircleSize = mRadius - res.getDimensionPixelSize(R.dimen.focus_radius_offset); - mRadiusInc = (int) res.getDimensionPixelSize(R.dimen.pie_radius_increment); - mTouchOffset = (int) res.getDimensionPixelSize(R.dimen.pie_touch_offset); - mCenter = new Point(0, 0); - mSelectedPaint = new Paint(); - mSelectedPaint.setColor(Color.argb(255, 51, 181, 229)); - mSelectedPaint.setAntiAlias(true); - mSubPaint = new Paint(); - mSubPaint.setAntiAlias(true); - mSubPaint.setColor(Color.argb(200, 250, 230, 128)); - mFocusPaint = new Paint(); - mFocusPaint.setAntiAlias(true); - mFocusPaint.setColor(Color.WHITE); - mFocusPaint.setStyle(Paint.Style.STROKE); - mSuccessColor = Color.GREEN; - mFailColor = Color.RED; - mCircle = new RectF(); - mDial = new RectF(); - mPoint1 = new Point(); - mPoint2 = new Point(); - mInnerOffset = res.getDimensionPixelSize(R.dimen.focus_inner_offset); - mOuterStroke = res.getDimensionPixelSize(R.dimen.focus_outer_stroke); - mInnerStroke = res.getDimensionPixelSize(R.dimen.focus_inner_stroke); - mState = STATE_IDLE; - mBlockFocus = false; - mTouchSlopSquared = ViewConfiguration.get(ctx).getScaledTouchSlop(); - mTouchSlopSquared = mTouchSlopSquared * mTouchSlopSquared; - mDown = new Point(); - } - - public boolean showsItems() { - return mTapMode; - } - - public void addItem(PieItem item) { - // add the item to the pie itself - mItems.add(item); - } - - public void removeItem(PieItem item) { - mItems.remove(item); - } - - public void clearItems() { - mItems.clear(); - } - - public void showInCenter() { - if ((mState == STATE_PIE) && isVisible()) { - mTapMode = false; - show(false); - } else { - if (mState != STATE_IDLE) { - cancelFocus(); - } - mState = STATE_PIE; - setCenter(mCenterX, mCenterY); - mTapMode = true; - show(true); - } - } - - public void hide() { - show(false); - } - - /** - * guaranteed has center set - * @param show - */ - private void show(boolean show) { - if (show) { - mState = STATE_PIE; - // ensure clean state - mCurrentItem = null; - mOpenItem = null; - for (PieItem item : mItems) { - item.setSelected(false); - } - layoutPie(); - fadeIn(); - } else { - mState = STATE_IDLE; - mTapMode = false; - if (mXFade != null) { - mXFade.cancel(); - } - } - setVisible(show); - mHandler.sendEmptyMessage(show ? MSG_OPEN : MSG_CLOSE); - } - - private void fadeIn() { - mFadeIn = new LinearAnimation(0, 1); - mFadeIn.setDuration(PIE_FADE_IN_DURATION); - mFadeIn.setAnimationListener(new AnimationListener() { - @Override - public void onAnimationStart(Animation animation) { - } - - @Override - public void onAnimationEnd(Animation animation) { - mFadeIn = null; - } - - @Override - public void onAnimationRepeat(Animation animation) { - } - }); - mFadeIn.startNow(); - mOverlay.startAnimation(mFadeIn); - } - - public void setCenter(int x, int y) { - mCenter.x = x; - mCenter.y = y; - // when using the pie menu, align the focus ring - alignFocus(x, y); - } - - private void layoutPie() { - int rgap = 2; - int inner = mRadius + rgap; - int outer = mRadius + mRadiusInc - rgap; - int gap = 1; - layoutItems(mItems, (float) (Math.PI / 2), inner, outer, gap); - } - - private void layoutItems(List items, float centerAngle, int inner, - int outer, int gap) { - float emptyangle = PIE_SWEEP / 16; - float sweep = (float) (PIE_SWEEP - 2 * emptyangle) / items.size(); - float angle = centerAngle - PIE_SWEEP / 2 + emptyangle + sweep / 2; - // check if we have custom geometry - // first item we find triggers custom sweep for all - // this allows us to re-use the path - for (PieItem item : items) { - if (item.getCenter() >= 0) { - sweep = item.getSweep(); - break; - } - } - Path path = makeSlice(getDegrees(0) - gap, getDegrees(sweep) + gap, - outer, inner, mCenter); - for (PieItem item : items) { - // shared between items - item.setPath(path); - if (item.getCenter() >= 0) { - angle = item.getCenter(); - } - int w = item.getIntrinsicWidth(); - int h = item.getIntrinsicHeight(); - // move views to outer border - int r = inner + (outer - inner) * 2 / 3; - int x = (int) (r * Math.cos(angle)); - int y = mCenter.y - (int) (r * Math.sin(angle)) - h / 2; - x = mCenter.x + x - w / 2; - item.setBounds(x, y, x + w, y + h); - float itemstart = angle - sweep / 2; - item.setGeometry(itemstart, sweep, inner, outer); - if (item.hasItems()) { - layoutItems(item.getItems(), angle, inner, - outer + mRadiusInc / 2, gap); - } - angle += sweep; - } - } - - private Path makeSlice(float start, float end, int outer, int inner, Point center) { - RectF bb = - new RectF(center.x - outer, center.y - outer, center.x + outer, - center.y + outer); - RectF bbi = - new RectF(center.x - inner, center.y - inner, center.x + inner, - center.y + inner); - Path path = new Path(); - path.arcTo(bb, start, end - start, true); - path.arcTo(bbi, end, start - end); - path.close(); - return path; - } - - /** - * converts a - * @param angle from 0..PI to Android degrees (clockwise starting at 3 o'clock) - * @return skia angle - */ - private float getDegrees(double angle) { - return (float) (360 - 180 * angle / Math.PI); - } - - private void startFadeOut() { - mOverlay.animate().alpha(0).setListener(new AnimatorListenerAdapter() { - @Override - public void onAnimationEnd(Animator animation) { - deselect(); - show(false); - mOverlay.setAlpha(1); - super.onAnimationEnd(animation); - } - }).setDuration(PIE_SELECT_FADE_DURATION); - } - - @Override - public void onDraw(Canvas canvas) { - float alpha = 1; - if (mXFade != null) { - alpha = mXFade.getValue(); - } else if (mFadeIn != null) { - alpha = mFadeIn.getValue(); - } - int state = canvas.save(); - if (mFadeIn != null) { - float sf = 0.9f + alpha * 0.1f; - canvas.scale(sf, sf, mCenter.x, mCenter.y); - } - drawFocus(canvas); - if (mState == STATE_FINISHING) { - canvas.restoreToCount(state); - return; - } - if ((mOpenItem == null) || (mXFade != null)) { - // draw base menu - for (PieItem item : mItems) { - drawItem(canvas, item, alpha); - } - } - if (mOpenItem != null) { - for (PieItem inner : mOpenItem.getItems()) { - drawItem(canvas, inner, (mXFade != null) ? (1 - 0.5f * alpha) : 1); - } - } - canvas.restoreToCount(state); - } - - private void drawItem(Canvas canvas, PieItem item, float alpha) { - if (mState == STATE_PIE) { - if (item.getPath() != null) { - if (item.isSelected()) { - Paint p = mSelectedPaint; - int state = canvas.save(); - float r = getDegrees(item.getStartAngle()); - canvas.rotate(r, mCenter.x, mCenter.y); - canvas.drawPath(item.getPath(), p); - canvas.restoreToCount(state); - } - alpha = alpha * (item.isEnabled() ? 1 : 0.3f); - // draw the item view - item.setAlpha(alpha); - item.draw(canvas); - } - } - } - - @Override - public boolean onTouchEvent(MotionEvent evt) { - float x = evt.getX(); - float y = evt.getY(); - int action = evt.getActionMasked(); - PointF polar = getPolar(x, y, !(mTapMode)); - if (MotionEvent.ACTION_DOWN == action) { - mDown.x = (int) evt.getX(); - mDown.y = (int) evt.getY(); - mOpening = false; - if (mTapMode) { - PieItem item = findItem(polar); - if ((item != null) && (mCurrentItem != item)) { - mState = STATE_PIE; - onEnter(item); - } - } else { - setCenter((int) x, (int) y); - show(true); - } - return true; - } else if (MotionEvent.ACTION_UP == action) { - if (isVisible()) { - PieItem item = mCurrentItem; - if (mTapMode) { - item = findItem(polar); - if (item != null && mOpening) { - mOpening = false; - return true; - } - } - if (item == null) { - mTapMode = false; - show(false); - } else if (!mOpening - && !item.hasItems()) { - item.performClick(); - startFadeOut(); - mTapMode = false; - } - return true; - } - } else if (MotionEvent.ACTION_CANCEL == action) { - if (isVisible() || mTapMode) { - show(false); - } - deselect(); - return false; - } else if (MotionEvent.ACTION_MOVE == action) { - if (polar.y < mRadius) { - if (mOpenItem != null) { - mOpenItem = null; - } else { - deselect(); - } - return false; - } - PieItem item = findItem(polar); - boolean moved = hasMoved(evt); - if ((item != null) && (mCurrentItem != item) && (!mOpening || moved)) { - // only select if we didn't just open or have moved past slop - mOpening = false; - if (moved) { - // switch back to swipe mode - mTapMode = false; - } - onEnter(item); - } - } - return false; - } - - private boolean hasMoved(MotionEvent e) { - return mTouchSlopSquared < (e.getX() - mDown.x) * (e.getX() - mDown.x) - + (e.getY() - mDown.y) * (e.getY() - mDown.y); - } - - /** - * enter a slice for a view - * updates model only - * @param item - */ - private void onEnter(PieItem item) { - if (mCurrentItem != null) { - mCurrentItem.setSelected(false); - } - if (item != null && item.isEnabled()) { - item.setSelected(true); - mCurrentItem = item; - if ((mCurrentItem != mOpenItem) && mCurrentItem.hasItems()) { - openCurrentItem(); - } - } else { - mCurrentItem = null; - } - } - - private void deselect() { - if (mCurrentItem != null) { - mCurrentItem.setSelected(false); - } - if (mOpenItem != null) { - mOpenItem = null; - } - mCurrentItem = null; - } - - private void openCurrentItem() { - if ((mCurrentItem != null) && mCurrentItem.hasItems()) { - mCurrentItem.setSelected(false); - mOpenItem = mCurrentItem; - mOpening = true; - mXFade = new LinearAnimation(1, 0); - mXFade.setDuration(PIE_XFADE_DURATION); - mXFade.setAnimationListener(new AnimationListener() { - @Override - public void onAnimationStart(Animation animation) { - } - - @Override - public void onAnimationEnd(Animation animation) { - mXFade = null; - } - - @Override - public void onAnimationRepeat(Animation animation) { - } - }); - mXFade.startNow(); - mOverlay.startAnimation(mXFade); - } - } - - private PointF getPolar(float x, float y, boolean useOffset) { - PointF res = new PointF(); - // get angle and radius from x/y - res.x = (float) Math.PI / 2; - x = x - mCenter.x; - y = mCenter.y - y; - res.y = (float) Math.sqrt(x * x + y * y); - if (x != 0) { - res.x = (float) Math.atan2(y, x); - if (res.x < 0) { - res.x = (float) (2 * Math.PI + res.x); - } - } - res.y = res.y + (useOffset ? mTouchOffset : 0); - return res; - } - - /** - * @param polar x: angle, y: dist - * @return the item at angle/dist or null - */ - private PieItem findItem(PointF polar) { - // find the matching item: - List items = (mOpenItem != null) ? mOpenItem.getItems() : mItems; - for (PieItem item : items) { - if (inside(polar, item)) { - return item; - } - } - return null; - } - - private boolean inside(PointF polar, PieItem item) { - return (item.getInnerRadius() < polar.y) - && (item.getStartAngle() < polar.x) - && (item.getStartAngle() + item.getSweep() > polar.x) - && (!mTapMode || (item.getOuterRadius() > polar.y)); - } - - @Override - public boolean handlesTouch() { - return true; - } - - // focus specific code - - public void setBlockFocus(boolean blocked) { - mBlockFocus = blocked; - if (blocked) { - clear(); - } - } - - public void setFocus(int x, int y) { - mFocusX = x; - mFocusY = y; - setCircle(mFocusX, mFocusY); - } - - public void alignFocus(int x, int y) { - mOverlay.removeCallbacks(mDisappear); - mAnimation.cancel(); - mAnimation.reset(); - mFocusX = x; - mFocusY = y; - mDialAngle = DIAL_HORIZONTAL; - setCircle(x, y); - mFocused = false; - } - - public int getSize() { - return 2 * mCircleSize; - } - - private int getRandomRange() { - return (int) (-60 + 120 * Math.random()); - } - - @Override - public void layout(int l, int t, int r, int b) { - super.layout(l, t, r, b); - mCenterX = (r - l) / 2; - mCenterY = (b - t) / 2; - mFocusX = mCenterX; - mFocusY = mCenterY; - setCircle(mFocusX, mFocusY); - if (isVisible() && mState == STATE_PIE) { - setCenter(mCenterX, mCenterY); - layoutPie(); - } - } - - private void setCircle(int cx, int cy) { - mCircle.set(cx - mCircleSize, cy - mCircleSize, - cx + mCircleSize, cy + mCircleSize); - mDial.set(cx - mCircleSize + mInnerOffset, cy - mCircleSize + mInnerOffset, - cx + mCircleSize - mInnerOffset, cy + mCircleSize - mInnerOffset); - } - - public void drawFocus(Canvas canvas) { - if (mBlockFocus) { - return; - } - mFocusPaint.setStrokeWidth(mOuterStroke); - canvas.drawCircle((float) mFocusX, (float) mFocusY, (float) mCircleSize, mFocusPaint); - if (mState == STATE_PIE) { - return; - } - int color = mFocusPaint.getColor(); - if (mState == STATE_FINISHING) { - mFocusPaint.setColor(mFocused ? mSuccessColor : mFailColor); - } - mFocusPaint.setStrokeWidth(mInnerStroke); - drawLine(canvas, mDialAngle, mFocusPaint); - drawLine(canvas, mDialAngle + 45, mFocusPaint); - drawLine(canvas, mDialAngle + 180, mFocusPaint); - drawLine(canvas, mDialAngle + 225, mFocusPaint); - canvas.save(); - // rotate the arc instead of its offset to better use framework's shape caching - canvas.rotate(mDialAngle, mFocusX, mFocusY); - canvas.drawArc(mDial, 0, 45, false, mFocusPaint); - canvas.drawArc(mDial, 180, 45, false, mFocusPaint); - canvas.restore(); - mFocusPaint.setColor(color); - } - - private void drawLine(Canvas canvas, int angle, Paint p) { - convertCart(angle, mCircleSize - mInnerOffset, mPoint1); - convertCart(angle, mCircleSize - mInnerOffset + mInnerOffset / 3, mPoint2); - canvas.drawLine(mPoint1.x + mFocusX, mPoint1.y + mFocusY, - mPoint2.x + mFocusX, mPoint2.y + mFocusY, p); - } - - private static void convertCart(int angle, int radius, Point out) { - double a = 2 * Math.PI * (angle % 360) / 360; - out.x = (int) (radius * Math.cos(a) + 0.5); - out.y = (int) (radius * Math.sin(a) + 0.5); - } - - @Override - public void showStart() { - if (mState == STATE_PIE) { - return; - } - cancelFocus(); - mStartAnimationAngle = 67; - int range = getRandomRange(); - startAnimation(SCALING_UP_TIME, - false, mStartAnimationAngle, mStartAnimationAngle + range); - mState = STATE_FOCUSING; - } - - @Override - public void showSuccess(boolean timeout) { - if (mState == STATE_FOCUSING) { - startAnimation(SCALING_DOWN_TIME, - timeout, mStartAnimationAngle); - mState = STATE_FINISHING; - mFocused = true; - } - } - - @Override - public void showFail(boolean timeout) { - if (mState == STATE_FOCUSING) { - startAnimation(SCALING_DOWN_TIME, - timeout, mStartAnimationAngle); - mState = STATE_FINISHING; - mFocused = false; - } - } - - private void cancelFocus() { - mFocusCancelled = true; - mOverlay.removeCallbacks(mDisappear); - if (mAnimation != null) { - mAnimation.cancel(); - } - mFocusCancelled = false; - mFocused = false; - mState = STATE_IDLE; - } - - @Override - public void clear() { - if (mState == STATE_PIE) { - return; - } - cancelFocus(); - mOverlay.post(mDisappear); - } - - private void startAnimation(long duration, boolean timeout, - float toScale) { - startAnimation(duration, timeout, mDialAngle, - toScale); - } - - private void startAnimation(long duration, boolean timeout, - float fromScale, float toScale) { - setVisible(true); - mAnimation.reset(); - mAnimation.setDuration(duration); - mAnimation.setScale(fromScale, toScale); - mAnimation.setAnimationListener(timeout ? mEndAction : null); - mOverlay.startAnimation(mAnimation); - update(); - } - - private class EndAction implements Animation.AnimationListener { - @Override - public void onAnimationEnd(Animation animation) { - // Keep the focus indicator for some time. - if (!mFocusCancelled) { - mOverlay.postDelayed(mDisappear, DISAPPEAR_TIMEOUT); - } - } - - @Override - public void onAnimationRepeat(Animation animation) { - } - - @Override - public void onAnimationStart(Animation animation) { - } - } - - private class Disappear implements Runnable { - @Override - public void run() { - if (mState == STATE_PIE) { - return; - } - setVisible(false); - mFocusX = mCenterX; - mFocusY = mCenterY; - mState = STATE_IDLE; - setCircle(mFocusX, mFocusY); - mFocused = false; - } - } - - private class ScaleAnimation extends Animation { - private float mFrom = 1f; - private float mTo = 1f; - - public ScaleAnimation() { - setFillAfter(true); - } - - public void setScale(float from, float to) { - mFrom = from; - mTo = to; - } - - @Override - protected void applyTransformation(float interpolatedTime, Transformation t) { - mDialAngle = (int) (mFrom + (mTo - mFrom) * interpolatedTime); - } - } - - - private class LinearAnimation extends Animation { - private float mFrom; - private float mTo; - private float mValue; - - public LinearAnimation(float from, float to) { - setFillAfter(true); - setInterpolator(new LinearInterpolator()); - mFrom = from; - mTo = to; - } - - public float getValue() { - return mValue; - } - - @Override - protected void applyTransformation(float interpolatedTime, Transformation t) { - mValue = (mFrom + (mTo - mFrom) * interpolatedTime); - } - } - -} \ No newline at end of file diff --git a/src/com/android/messaging/ui/mediapicker/camerafocus/README.txt b/src/com/android/messaging/ui/mediapicker/camerafocus/README.txt deleted file mode 100644 index ed4e783b..00000000 --- a/src/com/android/messaging/ui/mediapicker/camerafocus/README.txt +++ /dev/null @@ -1,3 +0,0 @@ -The files in this package were copied from the android-4.4.4_r1 branch of ASOP from the folders -com/android/camera/ and com/android/camera/ui from files with the same name. Some modifications -have been made to remove unneeded features and adjust to our needs. \ No newline at end of file diff --git a/src/com/android/messaging/ui/mediapicker/camerafocus/RenderOverlay.java b/src/com/android/messaging/ui/mediapicker/camerafocus/RenderOverlay.java deleted file mode 100644 index 95cddc4e..00000000 --- a/src/com/android/messaging/ui/mediapicker/camerafocus/RenderOverlay.java +++ /dev/null @@ -1,178 +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.mediapicker.camerafocus; - -import android.content.Context; -import android.graphics.Canvas; -import android.util.AttributeSet; -import android.view.MotionEvent; -import android.view.View; -import android.widget.FrameLayout; - -import java.util.ArrayList; -import java.util.List; - -public class RenderOverlay extends FrameLayout { - - interface Renderer { - - public boolean handlesTouch(); - public boolean onTouchEvent(MotionEvent evt); - public void setOverlay(RenderOverlay overlay); - public void layout(int left, int top, int right, int bottom); - public void draw(Canvas canvas); - - } - - private RenderView mRenderView; - private List mClients; - - // reverse list of touch clients - private List mTouchClients; - private int[] mPosition = new int[2]; - - public RenderOverlay(Context context, AttributeSet attrs) { - super(context, attrs); - mRenderView = new RenderView(context); - addView(mRenderView, new LayoutParams(LayoutParams.MATCH_PARENT, - LayoutParams.MATCH_PARENT)); - mClients = new ArrayList(10); - mTouchClients = new ArrayList(10); - setWillNotDraw(false); - - addRenderer(new PieRenderer(context)); - } - - public PieRenderer getPieRenderer() { - for (Renderer renderer : mClients) { - if (renderer instanceof PieRenderer) { - return (PieRenderer) renderer; - } - } - return null; - } - - public void addRenderer(Renderer renderer) { - mClients.add(renderer); - renderer.setOverlay(this); - if (renderer.handlesTouch()) { - mTouchClients.add(0, renderer); - } - renderer.layout(getLeft(), getTop(), getRight(), getBottom()); - } - - public void addRenderer(int pos, Renderer renderer) { - mClients.add(pos, renderer); - renderer.setOverlay(this); - renderer.layout(getLeft(), getTop(), getRight(), getBottom()); - } - - public void remove(Renderer renderer) { - mClients.remove(renderer); - renderer.setOverlay(null); - } - - public int getClientSize() { - return mClients.size(); - } - - @Override - public boolean dispatchTouchEvent(MotionEvent m) { - return false; - } - - public boolean directDispatchTouch(MotionEvent m, Renderer target) { - mRenderView.setTouchTarget(target); - boolean res = super.dispatchTouchEvent(m); - mRenderView.setTouchTarget(null); - return res; - } - - private void adjustPosition() { - getLocationInWindow(mPosition); - } - - public int getWindowPositionX() { - return mPosition[0]; - } - - public int getWindowPositionY() { - return mPosition[1]; - } - - public void update() { - mRenderView.invalidate(); - } - - private class RenderView extends View { - - private Renderer mTouchTarget; - - public RenderView(Context context) { - super(context); - setWillNotDraw(false); - } - - public void setTouchTarget(Renderer target) { - mTouchTarget = target; - } - - @Override - public boolean onTouchEvent(MotionEvent evt) { - if (mTouchTarget != null) { - return mTouchTarget.onTouchEvent(evt); - } - if (mTouchClients != null) { - boolean res = false; - for (Renderer client : mTouchClients) { - res |= client.onTouchEvent(evt); - } - return res; - } - return false; - } - - @Override - public void onLayout(boolean changed, int left, int top, int right, int bottom) { - adjustPosition(); - super.onLayout(changed, left, top, right, bottom); - if (mClients == null) { - return; - } - for (Renderer renderer : mClients) { - renderer.layout(left, top, right, bottom); - } - } - - @Override - public void draw(Canvas canvas) { - super.draw(canvas); - if (mClients == null) { - return; - } - boolean redraw = false; - for (Renderer renderer : mClients) { - renderer.draw(canvas); - redraw = redraw || ((OverlayRenderer) renderer).isVisible(); - } - if (redraw) { - invalidate(); - } - } - } - -} \ No newline at end of file From 0f9b472f7b1752ee4abc0d2c855f99bc9838a544 Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Thu, 14 May 2026 21:36:27 +0300 Subject: [PATCH 132/136] Fix camera permission overlay covered by camera controls --- .../ConversationMediaPickerCaptureRoute.kt | 2 + .../ConversationMediaPickerCaptureScene.kt | 4 ++ .../capture/ConversationMediaPickerCapture.kt | 58 +++++++++++-------- 3 files changed, 39 insertions(+), 25 deletions(-) diff --git a/src/com/android/messaging/ui/conversation/mediapicker/ConversationMediaPickerCaptureRoute.kt b/src/com/android/messaging/ui/conversation/mediapicker/ConversationMediaPickerCaptureRoute.kt index fd2dbb5b..87dbf254 100644 --- a/src/com/android/messaging/ui/conversation/mediapicker/ConversationMediaPickerCaptureRoute.kt +++ b/src/com/android/messaging/ui/conversation/mediapicker/ConversationMediaPickerCaptureRoute.kt @@ -17,6 +17,7 @@ internal fun ConversationMediaCaptureRoute( cameraController: ConversationCameraController, audioPermissionGranted: Boolean, captureMode: ConversationCaptureMode, + cameraPermissionGranted: Boolean, onClose: () -> Unit, onRequestAudioPermission: () -> Unit, onAttachmentStartRequest: () -> Boolean, @@ -37,6 +38,7 @@ internal fun ConversationMediaCaptureRoute( modifier = modifier, audioPermissionGranted = audioPermissionGranted, captureMode = captureMode, + cameraPermissionGranted = cameraPermissionGranted, hasFlashUnit = hasFlashUnit.value, isPhotoCaptureInProgress = isPhotoCaptureInProgress.value, isRecording = isRecording.value, diff --git a/src/com/android/messaging/ui/conversation/mediapicker/ConversationMediaPickerCaptureScene.kt b/src/com/android/messaging/ui/conversation/mediapicker/ConversationMediaPickerCaptureScene.kt index bd58d797..d0e59bd2 100644 --- a/src/com/android/messaging/ui/conversation/mediapicker/ConversationMediaPickerCaptureScene.kt +++ b/src/com/android/messaging/ui/conversation/mediapicker/ConversationMediaPickerCaptureScene.kt @@ -36,6 +36,7 @@ internal fun ConversationMediaPickerCaptureScene( .fillMaxSize(), cameraController = cameraController, cameraPermissionGranted = cameraPermissionGranted, + contentPadding = contentPadding, onRequestCameraPermission = onRequestCameraPermission, ) @@ -46,6 +47,7 @@ internal fun ConversationMediaPickerCaptureScene( cameraController = cameraController, audioPermissionGranted = audioPermissionGranted, captureMode = captureMode, + cameraPermissionGranted = cameraPermissionGranted, onClose = onClose, onRequestAudioPermission = onRequestAudioPermission, onAttachmentStartRequest = onAttachmentStartRequest, @@ -61,6 +63,7 @@ private fun ConversationMediaCameraPreviewRoute( modifier: Modifier = Modifier, cameraController: ConversationCameraController, cameraPermissionGranted: Boolean, + contentPadding: PaddingValues, onRequestCameraPermission: () -> Unit, ) { val surfaceRequest = cameraController.surfaceRequest.collectAsStateWithLifecycle() @@ -68,6 +71,7 @@ private fun ConversationMediaCameraPreviewRoute( ConversationMediaCameraPreviewSurface( modifier = modifier, cameraPermissionGranted = cameraPermissionGranted, + contentPadding = contentPadding, surfaceRequest = surfaceRequest.value, onRequestCameraPermission = onRequestCameraPermission, ) diff --git a/src/com/android/messaging/ui/conversation/mediapicker/component/capture/ConversationMediaPickerCapture.kt b/src/com/android/messaging/ui/conversation/mediapicker/component/capture/ConversationMediaPickerCapture.kt index 30e63ea0..d14b0fb0 100644 --- a/src/com/android/messaging/ui/conversation/mediapicker/component/capture/ConversationMediaPickerCapture.kt +++ b/src/com/android/messaging/ui/conversation/mediapicker/component/capture/ConversationMediaPickerCapture.kt @@ -4,6 +4,7 @@ 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.PaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding @@ -27,6 +28,7 @@ import com.android.messaging.ui.conversation.mediapicker.component.PermissionFal internal fun ConversationMediaCameraPreviewSurface( modifier: Modifier = Modifier, cameraPermissionGranted: Boolean, + contentPadding: PaddingValues, surfaceRequest: SurfaceRequest?, onRequestCameraPermission: () -> Unit, ) { @@ -37,6 +39,7 @@ internal fun ConversationMediaCameraPreviewSurface( when { !cameraPermissionGranted -> { ConversationMediaCameraPermissionFallback( + contentPadding = contentPadding, onRequestCameraPermission = onRequestCameraPermission, ) } @@ -56,11 +59,13 @@ internal fun ConversationMediaCameraPreviewSurface( @Composable private fun ConversationMediaCameraPermissionFallback( + contentPadding: PaddingValues, onRequestCameraPermission: () -> Unit, ) { Box( modifier = Modifier .fillMaxSize() + .padding(paddingValues = contentPadding) .padding(horizontal = 24.dp), contentAlignment = Alignment.Center, ) { @@ -109,6 +114,7 @@ internal fun ConversationMediaCaptureContent( modifier: Modifier = Modifier, audioPermissionGranted: Boolean, captureMode: ConversationCaptureMode, + cameraPermissionGranted: Boolean, hasFlashUnit: Boolean, isPhotoCaptureInProgress: Boolean, isRecording: Boolean, @@ -133,7 +139,7 @@ internal fun ConversationMediaCaptureContent( .statusBarsPadding() .padding(horizontal = 16.dp, vertical = 12.dp), captureMode = captureMode, - hasFlashUnit = hasFlashUnit, + hasFlashUnit = cameraPermissionGranted && hasFlashUnit, isPhotoCaptureInProgress = isPhotoCaptureInProgress, isRecording = isRecording, photoFlashMode = photoFlashMode, @@ -141,32 +147,34 @@ internal fun ConversationMediaCaptureContent( 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() - } + if (cameraPermissionGranted) { + 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 -> onVideoCaptureClick() + } } - } - else -> onPhotoCaptureClick() - } - }, - onPhotoModeClick = onPhotoModeClick, - onSwitchCameraClick = onSwitchCameraClick, - onVideoModeClick = onVideoModeClick, - ) + else -> onPhotoCaptureClick() + } + }, + onPhotoModeClick = onPhotoModeClick, + onSwitchCameraClick = onSwitchCameraClick, + onVideoModeClick = onVideoModeClick, + ) + } } } From 3ced04b74bbbaeb25db9848b4d651816b5db2859 Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Thu, 14 May 2026 22:02:48 +0300 Subject: [PATCH 133/136] Fix media picker overlay colors in dark mode --- .../mediapicker/ConversationMediaPicker.kt | 10 +++++++++- .../ConversationMediaPickerShared.kt | 20 +++++++++++++------ .../ConversationMediaCaptureControls.kt | 8 +++++--- .../ConversationMediaCaptureShutterButton.kt | 16 ++++++++------- .../review/ConversationMediaPickerReview.kt | 3 ++- .../ConversationMediaReviewBackground.kt | 3 ++- .../review/ConversationMediaReviewPageCard.kt | 8 +++++--- 7 files changed, 46 insertions(+), 22 deletions(-) diff --git a/src/com/android/messaging/ui/conversation/mediapicker/ConversationMediaPicker.kt b/src/com/android/messaging/ui/conversation/mediapicker/ConversationMediaPicker.kt index 10d177fd..7e008d90 100644 --- a/src/com/android/messaging/ui/conversation/mediapicker/ConversationMediaPicker.kt +++ b/src/com/android/messaging/ui/conversation/mediapicker/ConversationMediaPicker.kt @@ -1,12 +1,14 @@ package com.android.messaging.ui.conversation.mediapicker import android.annotation.SuppressLint +import android.content.res.Configuration 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.isSystemInDarkTheme import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.BottomSheetScaffoldState import androidx.compose.material3.ExperimentalMaterial3Api @@ -121,7 +123,12 @@ private fun rememberVisualMediaAttachments( @RequiresExtension(extension = Build.VERSION_CODES.UPSIDE_DOWN_CAKE, version = 15) @Composable private fun rememberConversationEmbeddedPhotoPickerFeatureInfo(): EmbeddedPhotoPickerFeatureInfo { - return remember { + val themeNightMode = when { + isSystemInDarkTheme() -> Configuration.UI_MODE_NIGHT_YES + else -> Configuration.UI_MODE_NIGHT_NO + } + + return remember(themeNightMode) { EmbeddedPhotoPickerFeatureInfo.Builder() .setMaxSelectionLimit(MediaStore.getPickImagesMaxLimit()) .setMimeTypes( @@ -131,6 +138,7 @@ private fun rememberConversationEmbeddedPhotoPickerFeatureInfo(): EmbeddedPhotoP ), ) .setOrderedSelection(true) + .setThemeNightMode(themeNightMode) .build() } } diff --git a/src/com/android/messaging/ui/conversation/mediapicker/component/ConversationMediaPickerShared.kt b/src/com/android/messaging/ui/conversation/mediapicker/component/ConversationMediaPickerShared.kt index 652d02d8..396ed863 100644 --- a/src/com/android/messaging/ui/conversation/mediapicker/component/ConversationMediaPickerShared.kt +++ b/src/com/android/messaging/ui/conversation/mediapicker/component/ConversationMediaPickerShared.kt @@ -29,6 +29,14 @@ import androidx.compose.ui.unit.dp private val PICKER_CONTROL_BUTTON_SIZE = 48.dp +internal fun pickerOverlayContainerColor(alpha: Float): Color { + return Color.Black.copy(alpha = alpha) +} + +internal fun pickerOverlayContentColor(alpha: Float = 1f): Color { + return Color.White.copy(alpha = alpha) +} + @Composable internal fun PermissionFallback( icon: @Composable () -> Unit, @@ -95,7 +103,7 @@ internal fun PermissionFallback( internal fun PickerOverlayBackgroundButton( modifier: Modifier = Modifier, buttonSize: Dp = PICKER_CONTROL_BUTTON_SIZE, - containerColor: Color = MaterialTheme.colorScheme.scrim.copy(alpha = 0.48f), + containerColor: Color = pickerOverlayContainerColor(alpha = 0.48f), contentDescription: String, iconSize: Dp = 24.dp, imageVector: ImageVector, @@ -108,7 +116,7 @@ internal fun PickerOverlayBackgroundButton( shape = CircleShape, colors = IconButtonDefaults.filledIconButtonColors( containerColor = containerColor, - contentColor = MaterialTheme.colorScheme.inverseOnSurface, + contentColor = pickerOverlayContentColor(), ), ) { Icon( @@ -134,10 +142,10 @@ internal fun PickerOverlayIconButton( 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), + containerColor = pickerOverlayContainerColor(alpha = 0.5f), + contentColor = pickerOverlayContentColor(), + disabledContainerColor = pickerOverlayContainerColor(alpha = 0.25f), + disabledContentColor = pickerOverlayContentColor(alpha = 0.5f), ), shape = CircleShape, ) { diff --git a/src/com/android/messaging/ui/conversation/mediapicker/component/capture/ConversationMediaCaptureControls.kt b/src/com/android/messaging/ui/conversation/mediapicker/component/capture/ConversationMediaCaptureControls.kt index 1c363cc7..b332f282 100644 --- a/src/com/android/messaging/ui/conversation/mediapicker/component/capture/ConversationMediaCaptureControls.kt +++ b/src/com/android/messaging/ui/conversation/mediapicker/component/capture/ConversationMediaCaptureControls.kt @@ -31,6 +31,8 @@ import com.android.messaging.ui.conversation.audio.formatConversationAudioDurati 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 +import com.android.messaging.ui.conversation.mediapicker.component.pickerOverlayContainerColor +import com.android.messaging.ui.conversation.mediapicker.component.pickerOverlayContentColor @Composable internal fun ConversationMediaCaptureTopBar( @@ -147,7 +149,7 @@ private fun ConversationMediaCaptureModeToggle( ) { Surface( shape = CircleShape, - color = MaterialTheme.colorScheme.scrim.copy(alpha = 0.4f), + color = pickerOverlayContainerColor(alpha = 0.4f), ) { Row( modifier = Modifier @@ -190,7 +192,7 @@ private fun ConversationMediaCaptureModeChip( shape = CircleShape, color = when { isSelected -> MaterialTheme.colorScheme.secondaryContainer - else -> MaterialTheme.colorScheme.scrim.copy(alpha = 0f) + else -> pickerOverlayContainerColor(alpha = 0f) }, ) { Box( @@ -202,7 +204,7 @@ private fun ConversationMediaCaptureModeChip( text = label, color = when { isSelected -> MaterialTheme.colorScheme.onSecondaryContainer - else -> MaterialTheme.colorScheme.inverseOnSurface.copy(alpha = 0.9f) + else -> pickerOverlayContentColor(alpha = 0.9f) }, style = MaterialTheme.typography.labelLarge, ) diff --git a/src/com/android/messaging/ui/conversation/mediapicker/component/capture/ConversationMediaCaptureShutterButton.kt b/src/com/android/messaging/ui/conversation/mediapicker/component/capture/ConversationMediaCaptureShutterButton.kt index bc808c29..66cd8677 100644 --- a/src/com/android/messaging/ui/conversation/mediapicker/component/capture/ConversationMediaCaptureShutterButton.kt +++ b/src/com/android/messaging/ui/conversation/mediapicker/component/capture/ConversationMediaCaptureShutterButton.kt @@ -27,6 +27,8 @@ import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.android.messaging.ui.conversation.mediapicker.ConversationCaptureMode +import com.android.messaging.ui.conversation.mediapicker.component.pickerOverlayContainerColor +import com.android.messaging.ui.conversation.mediapicker.component.pickerOverlayContentColor 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 @@ -78,7 +80,7 @@ private fun ConversationMediaCaptureShutterButtonAnimatedContent( ) ConversationMediaCaptureShutterButtonShell( - borderColor = colorScheme.inverseOnSurface, + borderColor = pickerOverlayContentColor(), isEnabled = isEnabled, onClick = onClick, outerContainerColor = visualState.outerContainerColor, @@ -384,20 +386,20 @@ private enum class ConversationMediaCaptureShutterPhase { fun toVisualState(colorScheme: ColorScheme): ConversationMediaCaptureShutterVisualState { return when (this) { Photo -> ConversationMediaCaptureShutterVisualState( - innerShutterColor = colorScheme.inverseOnSurface, + innerShutterColor = pickerOverlayContentColor(), innerShutterSize = PICKER_SHUTTER_PHOTO_INNER_SIZE, - outerContainerColor = colorScheme.scrim.copy(alpha = 0.2f), + outerContainerColor = pickerOverlayContainerColor(alpha = 0.2f), outerScale = 1f, recordingStopAlpha = 0f, recordingStopBackgroundColor = colorScheme.error.copy(alpha = 0.3f), recordingStopScale = 0.8f, videoCenterDotAlpha = 0f, - videoCenterDotColor = colorScheme.inverseOnSurface, + videoCenterDotColor = pickerOverlayContentColor(), videoCenterDotScale = 0.7f, ) VideoIdle -> ConversationMediaCaptureShutterVisualState( - innerShutterColor = colorScheme.scrim.copy(alpha = 0.5f), + innerShutterColor = pickerOverlayContainerColor(alpha = 0.5f), innerShutterSize = PICKER_SHUTTER_FULL_INNER_SIZE, outerContainerColor = Color.Transparent, outerScale = 1f, @@ -405,7 +407,7 @@ private enum class ConversationMediaCaptureShutterPhase { recordingStopBackgroundColor = colorScheme.error.copy(alpha = 0.3f), recordingStopScale = 0.8f, videoCenterDotAlpha = 1f, - videoCenterDotColor = colorScheme.inverseOnSurface, + videoCenterDotColor = pickerOverlayContentColor(), videoCenterDotScale = 1f, ) @@ -418,7 +420,7 @@ private enum class ConversationMediaCaptureShutterPhase { recordingStopBackgroundColor = colorScheme.error.copy(alpha = 0.3f), recordingStopScale = 1f, videoCenterDotAlpha = 0f, - videoCenterDotColor = colorScheme.inverseOnSurface, + videoCenterDotColor = pickerOverlayContentColor(), videoCenterDotScale = 0.7f, ) } diff --git a/src/com/android/messaging/ui/conversation/mediapicker/component/review/ConversationMediaPickerReview.kt b/src/com/android/messaging/ui/conversation/mediapicker/component/review/ConversationMediaPickerReview.kt index fabc9158..fead995b 100644 --- a/src/com/android/messaging/ui/conversation/mediapicker/component/review/ConversationMediaPickerReview.kt +++ b/src/com/android/messaging/ui/conversation/mediapicker/component/review/ConversationMediaPickerReview.kt @@ -48,6 +48,7 @@ import com.android.messaging.ui.conversation.composer.model.ComposerAttachmentUi 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 com.android.messaging.ui.conversation.mediapicker.component.pickerOverlayContentColor import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableMap import kotlinx.collections.immutable.persistentMapOf @@ -199,7 +200,7 @@ private fun ConversationMediaReviewTopBar( modifier = Modifier .weight(weight = 1f), text = conversationTitle.orEmpty(), - color = MaterialTheme.colorScheme.inverseOnSurface, + color = pickerOverlayContentColor(), maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.titleMedium, diff --git a/src/com/android/messaging/ui/conversation/mediapicker/component/review/ConversationMediaReviewBackground.kt b/src/com/android/messaging/ui/conversation/mediapicker/component/review/ConversationMediaReviewBackground.kt index 3cc9f163..6531aaa7 100644 --- a/src/com/android/messaging/ui/conversation/mediapicker/component/review/ConversationMediaReviewBackground.kt +++ b/src/com/android/messaging/ui/conversation/mediapicker/component/review/ConversationMediaReviewBackground.kt @@ -21,6 +21,7 @@ import androidx.compose.ui.unit.IntSize import androidx.core.net.toUri import com.android.messaging.ui.conversation.attachment.ui.loadConversationMediaThumbnailBitmap import com.android.messaging.ui.conversation.composer.model.ComposerAttachmentUiModel +import com.android.messaging.ui.conversation.mediapicker.component.pickerOverlayContainerColor import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList @@ -69,7 +70,7 @@ private fun ConversationMediaReviewBackgroundContent( modifier = Modifier .fillMaxSize() .background( - color = MaterialTheme.colorScheme.scrim.copy(alpha = 0.5f), + color = pickerOverlayContainerColor(alpha = 0.5f), ), ) } diff --git a/src/com/android/messaging/ui/conversation/mediapicker/component/review/ConversationMediaReviewPageCard.kt b/src/com/android/messaging/ui/conversation/mediapicker/component/review/ConversationMediaReviewPageCard.kt index cddc2375..d5b8b4f4 100644 --- a/src/com/android/messaging/ui/conversation/mediapicker/component/review/ConversationMediaReviewPageCard.kt +++ b/src/com/android/messaging/ui/conversation/mediapicker/component/review/ConversationMediaReviewPageCard.kt @@ -42,6 +42,8 @@ import com.android.messaging.R 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 com.android.messaging.ui.conversation.mediapicker.component.pickerOverlayContainerColor +import com.android.messaging.ui.conversation.mediapicker.component.pickerOverlayContentColor import kotlin.math.absoluteValue import kotlinx.collections.immutable.ImmutableList import kotlinx.coroutines.delay @@ -257,13 +259,13 @@ private fun ConversationMediaReviewVideoBadge( Surface( modifier = modifier, shape = CircleShape, - color = MaterialTheme.colorScheme.scrim.copy(alpha = 0.5f), + color = pickerOverlayContainerColor(alpha = 0.5f), ) { Icon( modifier = Modifier.padding(12.dp), imageVector = Icons.Rounded.PlayArrow, contentDescription = null, - tint = MaterialTheme.colorScheme.inverseOnSurface, + tint = pickerOverlayContentColor(), ) } } @@ -286,7 +288,7 @@ private fun ConversationMediaReviewDeleteButton( scaleX = scale scaleY = scale }, - containerColor = MaterialTheme.colorScheme.scrim.copy(alpha = 0.5f), + containerColor = pickerOverlayContainerColor(alpha = 0.5f), contentDescription = stringResource( id = R.string.conversation_media_picker_remove_attachment_content_description, ), From 51d6f8033d6fabdda239824589abccb9b28aede4 Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Thu, 14 May 2026 22:32:10 +0300 Subject: [PATCH 134/136] Fix audio recording state issue when audio permission is not granted --- .../ConversationAudioRecordingDelegate.kt | 29 +++++++++++------ .../ConversationMediaCaptureShutterButton.kt | 4 +-- ...tartMode.kt => AudioRecordingStartMode.kt} | 3 +- .../conversation/screen/ConversationScreen.kt | 4 +-- .../screen/ConversationScreenRoute.kt | 31 +++---------------- 5 files changed, 28 insertions(+), 43 deletions(-) rename src/com/android/messaging/ui/conversation/screen/{PendingAudioRecordingStartMode.kt => AudioRecordingStartMode.kt} (56%) diff --git a/src/com/android/messaging/ui/conversation/audio/delegate/ConversationAudioRecordingDelegate.kt b/src/com/android/messaging/ui/conversation/audio/delegate/ConversationAudioRecordingDelegate.kt index 2aab1a19..24dac7d3 100644 --- a/src/com/android/messaging/ui/conversation/audio/delegate/ConversationAudioRecordingDelegate.kt +++ b/src/com/android/messaging/ui/conversation/audio/delegate/ConversationAudioRecordingDelegate.kt @@ -104,7 +104,10 @@ internal class ConversationAudioRecordingDelegateImpl @Inject constructor( context = defaultDispatcher, start = CoroutineStart.LAZY, ) { - startRecordingInBackground(selfParticipantId = selfParticipantId) + startRecordingInBackground( + scope = scope, + selfParticipantId = selfParticipantId, + ) } val shouldStartJob = withSessionStateLock { @@ -360,7 +363,10 @@ internal class ConversationAudioRecordingDelegateImpl @Inject constructor( } } - private suspend fun startRecordingInBackground(selfParticipantId: String) { + private suspend fun startRecordingInBackground( + scope: CoroutineScope, + selfParticipantId: String, + ) { val resolvedMediaRecorder = LevelTrackingMediaRecorder() val maxMessageSize = subscriptionsRepository .resolveMaxMessageSize(selfParticipantId = selfParticipantId) @@ -381,7 +387,10 @@ internal class ConversationAudioRecordingDelegateImpl @Inject constructor( } val startedAtMillis = SystemClock.elapsedRealtime() - val durationJob = boundScope?.launch(defaultDispatcher) { + val durationJob = scope.launch( + context = defaultDispatcher, + start = CoroutineStart.LAZY, + ) { bindDurationTicker(startedAtMillis = startedAtMillis) } @@ -394,11 +403,13 @@ internal class ConversationAudioRecordingDelegateImpl @Inject constructor( } runAudioRecordingEffect( - scope = requireNotNull(boundScope) { - "Bound scope must be available while recording starts" - }, + scope = scope, effect = effect, ) + + if (effect == AudioRecordingEffect.None) { + durationJob.start() + } } private fun clearStartingSessionLocked() { @@ -411,7 +422,7 @@ internal class ConversationAudioRecordingDelegateImpl @Inject constructor( private fun completeRecorderStartLocked( mediaRecorder: LevelTrackingMediaRecorder, startedAtMillis: Long, - durationJob: Job?, + durationJob: Job, ): AudioRecordingEffect { val currentSessionState = sessionState as? AudioRecordingSessionState.Starting @@ -439,9 +450,7 @@ internal class ConversationAudioRecordingDelegateImpl @Inject constructor( startedAtMillis = startedAtMillis, durationMillis = 0L, isLocked = currentSessionState.queuedIntent == QueuedStartIntent.Lock, - durationJob = requireNotNull(durationJob) { - "Duration job must be available for active recording" - }, + durationJob = durationJob, ) publishUiStateLocked() diff --git a/src/com/android/messaging/ui/conversation/mediapicker/component/capture/ConversationMediaCaptureShutterButton.kt b/src/com/android/messaging/ui/conversation/mediapicker/component/capture/ConversationMediaCaptureShutterButton.kt index 66cd8677..a39a79a5 100644 --- a/src/com/android/messaging/ui/conversation/mediapicker/component/capture/ConversationMediaCaptureShutterButton.kt +++ b/src/com/android/messaging/ui/conversation/mediapicker/component/capture/ConversationMediaCaptureShutterButton.kt @@ -27,11 +27,11 @@ import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.android.messaging.ui.conversation.mediapicker.ConversationCaptureMode -import com.android.messaging.ui.conversation.mediapicker.component.pickerOverlayContainerColor -import com.android.messaging.ui.conversation.mediapicker.component.pickerOverlayContentColor 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 +import com.android.messaging.ui.conversation.mediapicker.component.pickerOverlayContainerColor +import com.android.messaging.ui.conversation.mediapicker.component.pickerOverlayContentColor 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/screen/PendingAudioRecordingStartMode.kt b/src/com/android/messaging/ui/conversation/screen/AudioRecordingStartMode.kt similarity index 56% rename from src/com/android/messaging/ui/conversation/screen/PendingAudioRecordingStartMode.kt rename to src/com/android/messaging/ui/conversation/screen/AudioRecordingStartMode.kt index cfc3d942..e8905d01 100644 --- a/src/com/android/messaging/ui/conversation/screen/PendingAudioRecordingStartMode.kt +++ b/src/com/android/messaging/ui/conversation/screen/AudioRecordingStartMode.kt @@ -1,7 +1,6 @@ package com.android.messaging.ui.conversation.screen -internal enum class PendingAudioRecordingStartMode { - None, +internal enum class AudioRecordingStartMode { Unlocked, Locked, } diff --git a/src/com/android/messaging/ui/conversation/screen/ConversationScreen.kt b/src/com/android/messaging/ui/conversation/screen/ConversationScreen.kt index da1722bf..f8419a55 100644 --- a/src/com/android/messaging/ui/conversation/screen/ConversationScreen.kt +++ b/src/com/android/messaging/ui/conversation/screen/ConversationScreen.kt @@ -117,10 +117,10 @@ internal fun ConversationScreen( }, onOpenContactPicker = onOpenContactPicker, onAudioRecordingStartRequest = { - requestAudioRecordingStart(PendingAudioRecordingStartMode.Unlocked) + requestAudioRecordingStart(AudioRecordingStartMode.Unlocked) }, onLockedAudioRecordingStartRequest = { - requestAudioRecordingStart(PendingAudioRecordingStartMode.Locked) + requestAudioRecordingStart(AudioRecordingStartMode.Locked) }, screenModel = screenModel, ) diff --git a/src/com/android/messaging/ui/conversation/screen/ConversationScreenRoute.kt b/src/com/android/messaging/ui/conversation/screen/ConversationScreenRoute.kt index 0972268f..34920902 100644 --- a/src/com/android/messaging/ui/conversation/screen/ConversationScreenRoute.kt +++ b/src/com/android/messaging/ui/conversation/screen/ConversationScreenRoute.kt @@ -12,11 +12,7 @@ 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 @@ -57,27 +53,11 @@ internal fun rememberOpenContactPickerCallback( internal fun rememberAudioRecordingStartRequest( screenModel: ConversationScreenModel, permissionState: ConversationMediaPickerPermissionState, -): (PendingAudioRecordingStartMode) -> Unit { - var pendingAudioRecordingStartMode by rememberSaveable { - mutableStateOf(value = PendingAudioRecordingStartMode.None) - } - +): (AudioRecordingStartMode) -> Unit { val audioPermissionLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.RequestPermission(), ) { isGranted -> permissionState.audioPermissionGranted = isGranted - - val startMode = pendingAudioRecordingStartMode - pendingAudioRecordingStartMode = PendingAudioRecordingStartMode.None - - when { - isGranted -> { - startAudioRecording( - screenModel = screenModel, - startMode = startMode, - ) - } - } } return remember(screenModel, permissionState, audioPermissionLauncher) { @@ -95,7 +75,6 @@ internal fun rememberAudioRecordingStartRequest( } else -> { - pendingAudioRecordingStartMode = startMode audioPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO) } } @@ -333,16 +312,14 @@ private fun ConversationMediaPickerOverlayHost( private fun startAudioRecording( screenModel: ConversationScreenModel, - startMode: PendingAudioRecordingStartMode, + startMode: AudioRecordingStartMode, ) { when (startMode) { - PendingAudioRecordingStartMode.None -> {} - - PendingAudioRecordingStartMode.Unlocked -> { + AudioRecordingStartMode.Unlocked -> { screenModel.onAudioRecordingStart() } - PendingAudioRecordingStartMode.Locked -> { + AudioRecordingStartMode.Locked -> { screenModel.onLockedAudioRecordingStart() } } From 93e579bd71055ce36b90ea28e3221999941bf378 Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Thu, 14 May 2026 22:46:53 +0300 Subject: [PATCH 135/136] Round conversation content corners --- .../conversation/screen/ConversationScreen.kt | 48 +++++++++++++++++-- 1 file changed, 44 insertions(+), 4 deletions(-) diff --git a/src/com/android/messaging/ui/conversation/screen/ConversationScreen.kt b/src/com/android/messaging/ui/conversation/screen/ConversationScreen.kt index f8419a55..baee7d9a 100644 --- a/src/com/android/messaging/ui/conversation/screen/ConversationScreen.kt +++ b/src/com/android/messaging/ui/conversation/screen/ConversationScreen.kt @@ -1,11 +1,14 @@ package com.android.messaging.ui.conversation.screen +import androidx.compose.foundation.background 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.foundation.shape.RoundedCornerShape import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.SnackbarHost @@ -21,10 +24,13 @@ import androidx.compose.runtime.setValue 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.focus.FocusRequester import androidx.compose.ui.geometry.Rect as ComposeRect +import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.android.messaging.R @@ -44,6 +50,12 @@ import com.android.messaging.ui.conversation.screen.model.ConversationScreenScaf import kotlinx.collections.immutable.ImmutableList private const val SMOOTH_SCROLL_JUMP_THRESHOLD = 15 +private val CONVERSATION_CONTENT_SHAPE = RoundedCornerShape( + topStart = 28.dp, + topEnd = 28.dp, + bottomStart = 0.dp, + bottomEnd = 0.dp, +) @Composable internal fun ConversationScreen( @@ -333,12 +345,15 @@ private fun ConversationScreenContent( onMessageResendClick: (String) -> Unit, onSimSelectorClick: () -> Unit, ) { + val contentBackdropColor = conversationScreenContentBackdropColor(uiState = uiState) + when (val messagesState = uiState.messages) { is ConversationMessagesUiState.Loading -> { Box( - modifier = modifier - .fillMaxSize() - .padding(paddingValues = contentPadding), + modifier = modifier.conversationScreenContentModifier( + contentPadding = contentPadding, + backdropColor = contentBackdropColor, + ), contentAlignment = Alignment.Center, ) { CircularProgressIndicator( @@ -372,7 +387,10 @@ private fun ConversationScreenContent( ) ConversationMessages( - modifier = modifier.padding(paddingValues = contentPadding), + modifier = modifier.conversationScreenContentModifier( + contentPadding = contentPadding, + backdropColor = contentBackdropColor, + ), messages = messagesState.messages, listState = messagesListState, selectedMessageIds = uiState.selection.selectedMessageIds, @@ -391,6 +409,28 @@ private fun ConversationScreenContent( } } +@Composable +private fun Modifier.conversationScreenContentModifier( + contentPadding: PaddingValues, + backdropColor: Color, +): Modifier { + return this + .padding(paddingValues = contentPadding) + .background(color = backdropColor) + .clip(shape = CONVERSATION_CONTENT_SHAPE) + .background(color = MaterialTheme.colorScheme.background) +} + +@Composable +private fun conversationScreenContentBackdropColor( + uiState: ConversationScreenScaffoldUiState, +): Color { + return when { + uiState.selection.isSelectionMode -> MaterialTheme.colorScheme.secondaryContainer + else -> MaterialTheme.colorScheme.surfaceContainer + } +} + private fun shouldShowIncomingParticipantIdentity( metadata: ConversationMetadataUiState, ): Boolean { From c5c209314679c1d77f171202af0a68576a10aca8 Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Thu, 14 May 2026 23:00:33 +0300 Subject: [PATCH 136/136] Fix top bar touch area size --- .../metadata/ui/ConversationTopAppBarTest.kt | 52 +++++++++++++++++++ .../ui/conversation/ConversationTestTags.kt | 1 + .../metadata/ui/ConversationTopAppBar.kt | 13 +++-- 3 files changed, 62 insertions(+), 4 deletions(-) create mode 100644 app/src/androidTest/java/com/android/messaging/ui/conversation/metadata/ui/ConversationTopAppBarTest.kt diff --git a/app/src/androidTest/java/com/android/messaging/ui/conversation/metadata/ui/ConversationTopAppBarTest.kt b/app/src/androidTest/java/com/android/messaging/ui/conversation/metadata/ui/ConversationTopAppBarTest.kt new file mode 100644 index 00000000..37c013ee --- /dev/null +++ b/app/src/androidTest/java/com/android/messaging/ui/conversation/metadata/ui/ConversationTopAppBarTest.kt @@ -0,0 +1,52 @@ +package com.android.messaging.ui.conversation.metadata.ui + +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.ui.test.assertHeightIsEqualTo +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import com.android.messaging.data.conversation.model.metadata.ConversationComposerAvailability +import com.android.messaging.ui.conversation.CONVERSATION_TOP_APP_BAR_TITLE_TEST_TAG +import com.android.messaging.ui.conversation.metadata.model.ConversationMetadataUiState +import com.android.messaging.ui.core.AppTheme +import org.junit.Rule +import org.junit.Test + +internal class ConversationTopAppBarTest { + @get:Rule + val composeRule = createComposeRule() + + @OptIn(ExperimentalMaterial3Api::class) + @Test + fun titleTouchTargetUsesSmallTopAppBarHeight() { + composeRule.setContent { + AppTheme { + ConversationTopAppBar( + metadata = conversationMetadata(), + isCallVisible = true, + onAddPeopleClick = {}, + onTitleClick = {}, + onNavigateBack = {}, + ) + } + } + + composeRule + .onNodeWithTag(testTag = CONVERSATION_TOP_APP_BAR_TITLE_TEST_TAG) + .assertHeightIsEqualTo(expectedHeight = TopAppBarDefaults.TopAppBarExpandedHeight) + } +} + +private fun conversationMetadata(): ConversationMetadataUiState { + return ConversationMetadataUiState.Present( + title = "+372 5440 0024", + selfParticipantId = "self-participant-id", + avatar = ConversationMetadataUiState.Avatar.Single(photoUri = null), + participantCount = 1, + otherParticipantDisplayDestination = "+372 5440 0024", + otherParticipantPhoneNumber = "+37254400024", + otherParticipantContactLookupKey = null, + isArchived = false, + composerAvailability = ConversationComposerAvailability.editable(), + ) +} diff --git a/src/com/android/messaging/ui/conversation/ConversationTestTags.kt b/src/com/android/messaging/ui/conversation/ConversationTestTags.kt index 473a4e70..e9624d65 100644 --- a/src/com/android/messaging/ui/conversation/ConversationTestTags.kt +++ b/src/com/android/messaging/ui/conversation/ConversationTestTags.kt @@ -44,6 +44,7 @@ internal const val NEW_CHAT_SIM_SELECTOR_DROPDOWN_TEST_TAG = "new_chat_sim_selec 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_TOP_APP_BAR_TITLE_TEST_TAG = "conversation_top_app_bar_title" 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" 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 b3d5ae77..75edcd58 100644 --- a/src/com/android/messaging/ui/conversation/metadata/ui/ConversationTopAppBar.kt +++ b/src/com/android/messaging/ui/conversation/metadata/ui/ConversationTopAppBar.kt @@ -7,6 +7,7 @@ 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.heightIn import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons @@ -64,6 +65,7 @@ import com.android.messaging.ui.conversation.CONVERSATION_DELETE_CONVERSATION_BU import com.android.messaging.ui.conversation.CONVERSATION_OVERFLOW_BUTTON_TEST_TAG import com.android.messaging.ui.conversation.CONVERSATION_SHOW_SUBJECT_FIELD_MENU_ITEM_TEST_TAG import com.android.messaging.ui.conversation.CONVERSATION_SIM_SELECTOR_MENU_ITEM_TEST_TAG +import com.android.messaging.ui.conversation.CONVERSATION_TOP_APP_BAR_TITLE_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 @@ -193,10 +195,13 @@ private fun ConversationTopAppBarTitle( presentation: ConversationTopAppBarPresentation, ) { Row( - modifier = Modifier.clickable( - enabled = isClickable, - onClick = onClick, - ), + modifier = Modifier + .heightIn(min = TopAppBarDefaults.TopAppBarExpandedHeight) + .testTag(tag = CONVERSATION_TOP_APP_BAR_TITLE_TEST_TAG) + .clickable( + enabled = isClickable, + onClick = onClick, + ), horizontalArrangement = Arrangement.spacedBy( space = CONVERSATION_TOP_APP_BAR_TITLE_SPACING, ),