diff --git a/app/src/test/kotlin/com/android/messaging/data/conversation/repository/conversations/ConversationsRepositoryActionsTest.kt b/app/src/test/kotlin/com/android/messaging/data/conversation/repository/conversations/ConversationsRepositoryActionsTest.kt index 9b0718b38..a1c855e4c 100644 --- a/app/src/test/kotlin/com/android/messaging/data/conversation/repository/conversations/ConversationsRepositoryActionsTest.kt +++ b/app/src/test/kotlin/com/android/messaging/data/conversation/repository/conversations/ConversationsRepositoryActionsTest.kt @@ -36,6 +36,7 @@ internal class ConversationsRepositoryActionsTest : BaseConversationsRepositoryT every { UpdateConversationArchiveStatusAction.archiveConversation(any()) } just runs every { UpdateConversationArchiveStatusAction.unarchiveConversation(any()) } just runs every { DeleteConversationAction.deleteConversation(any(), any()) } just runs + every { DeleteConversationAction.deleteConversations(any()) } just runs } @After @@ -137,4 +138,37 @@ internal class ConversationsRepositoryActionsTest : BaseConversationsRepositoryT DeleteConversationAction.deleteConversation("conversation-delete", 456L) } } + + @Test + fun deleteConversations_skipsBlankIdsAndDelegatesTheRestInOneBatch() { + val repository = createRepository() + + repository.deleteConversations( + cutoffTimestampsByConversationId = mapOf( + ConversationId("conversation-1") to 123L, + ConversationId(" ") to 456L, + ConversationId("conversation-2") to 789L, + ), + ) + + verify(exactly = 1) { + DeleteConversationAction.deleteConversations( + mapOf( + "conversation-1" to 123L, + "conversation-2" to 789L, + ), + ) + } + } + + @Test + fun deleteConversations_skipsTheActionWhenEveryIdIsBlank() { + createRepository().deleteConversations( + cutoffTimestampsByConversationId = mapOf(ConversationId("") to 123L), + ) + + verify(exactly = 0) { + DeleteConversationAction.deleteConversations(any()) + } + } } diff --git a/app/src/test/kotlin/com/android/messaging/datamodel/action/DeleteConversationActionTest.kt b/app/src/test/kotlin/com/android/messaging/datamodel/action/DeleteConversationActionTest.kt new file mode 100644 index 000000000..6b5037692 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/datamodel/action/DeleteConversationActionTest.kt @@ -0,0 +1,97 @@ +package com.android.messaging.datamodel.action + +import com.android.messaging.FactoryTestAccess +import com.android.messaging.datamodel.BugleDatabaseOperations +import com.android.messaging.datamodel.BugleNotifications +import com.android.messaging.datamodel.DataModel +import com.android.messaging.datamodel.MessagingContentProvider +import com.android.messaging.sms.MmsUtils +import com.android.messaging.testutil.installTestFactory +import com.android.messaging.util.NotificationChannelUtil +import com.android.messaging.widget.WidgetConversationProvider +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.mockkStatic +import io.mockk.runs +import io.mockk.slot +import io.mockk.unmockkAll +import io.mockk.verify +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment + +@RunWith(RobolectricTestRunner::class) +class DeleteConversationActionTest { + + private val actionService = mockk() + private val startedAction = slot() + + @Before + fun setUp() { + val dataModel = mockk(relaxed = true) + every { dataModel.actionService } returns actionService + every { actionService.startAction(capture(startedAction)) } just runs + installTestFactory( + context = RuntimeEnvironment.getApplication().applicationContext, + dataModel = dataModel, + ) + mockkObject(NotificationChannelUtil) + every { NotificationChannelUtil.deleteChannel(any()) } just runs + mockkStatic(BugleDatabaseOperations::class) + every { BugleDatabaseOperations.getThreadId(any(), CONVERSATION_ID) } returns THREAD_ID + every { BugleDatabaseOperations.deleteConversation(any(), CONVERSATION_ID, any()) } returns + true + mockkStatic(MessagingContentProvider::class) + every { MessagingContentProvider.notifyConversationListChanged() } just runs + mockkStatic(WidgetConversationProvider::class) + every { WidgetConversationProvider.notifyConversationDeleted(any(), any()) } just runs + mockkStatic(BugleNotifications::class) + every { BugleNotifications.update(any(), any()) } just runs + mockkStatic(BugleActionToasts::class) + every { BugleActionToasts.onConversationsDeleted(any()) } just runs + every { BugleActionToasts.onFailedToDeleteConversations(any()) } just runs + mockkStatic(MmsUtils::class) + every { MmsUtils.deleteThread(THREAD_ID, any()) } returns 0 + } + + @After + fun tearDown() { + unmockkAll() + FactoryTestAccess.reset() + } + + @Test + fun draftOnlyConversationWithNoTelephonyMessagesReportsDeleted() { + every { MmsUtils.hasThreadMessages(THREAD_ID, any()) } returns false + + deleteConversation() + + verify { BugleActionToasts.onConversationsDeleted(1) } + verify(exactly = 0) { BugleActionToasts.onFailedToDeleteConversations(any()) } + } + + @Test + fun telephonyMessagesThatSurviveTheDeleteReportFailure() { + every { MmsUtils.hasThreadMessages(THREAD_ID, any()) } returns true + + deleteConversation() + + verify { BugleActionToasts.onFailedToDeleteConversations(1) } + verify(exactly = 0) { BugleActionToasts.onConversationsDeleted(any()) } + } + + private fun deleteConversation() { + DeleteConversationAction.deleteConversation(CONVERSATION_ID, Long.MAX_VALUE) + startedAction.captured.doBackgroundWork() + } + + private companion object { + private const val CONVERSATION_ID = "17" + private const val THREAD_ID = 42L + } +} diff --git a/app/src/test/kotlin/com/android/messaging/sms/MmsUtilsThreadMessagesTest.kt b/app/src/test/kotlin/com/android/messaging/sms/MmsUtilsThreadMessagesTest.kt new file mode 100644 index 000000000..26d53cfa9 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/sms/MmsUtilsThreadMessagesTest.kt @@ -0,0 +1,66 @@ +package com.android.messaging.sms + +import android.content.ContentResolver +import android.content.Context +import android.database.sqlite.SQLiteDatabase +import android.net.Uri +import com.android.messaging.FactoryTestAccess +import com.android.messaging.testutil.installTestFactory +import io.mockk.every +import io.mockk.mockk +import org.junit.After +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +/** + * MmsSmsProvider runs thread message queries without bind arguments, so the cutoff only reaches + * SQLite when it is part of the selection itself. + */ +@RunWith(RobolectricTestRunner::class) +class MmsUtilsThreadMessagesTest { + + private val database = SQLiteDatabase.create(null) + private val contentResolver = mockk() + private val context = mockk() + + @Before + fun setUp() { + database.execSQL("CREATE TABLE sms (_id INTEGER PRIMARY KEY, thread_id, date)") + database.execSQL("INSERT INTO sms (thread_id, date) VALUES ($THREAD_ID, $MESSAGE_DATE)") + every { context.contentResolver } returns contentResolver + every { contentResolver.query(any(), any(), any(), any(), any()) } answers { + val threadId = firstArg().lastPathSegment + val selection = thirdArg() + database.rawQuery( + "SELECT _id FROM sms WHERE thread_id = $threadId AND ($selection)", + emptyArray(), + ) + } + installTestFactory(context = context) + } + + @After + fun tearDown() { + database.close() + FactoryTestAccess.reset() + } + + @Test + fun messageUpToTheCutoffIsFound() { + assertTrue(MmsUtils.hasThreadMessages(THREAD_ID, MESSAGE_DATE)) + } + + @Test + fun messageAfterTheCutoffIsIgnored() { + assertFalse(MmsUtils.hasThreadMessages(THREAD_ID, MESSAGE_DATE - 1)) + } + + private companion object { + private const val THREAD_ID = 42L + private const val MESSAGE_DATE = 1_790_000_000_000L + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/common/components/composer/MessageComposeBarInputTypeTest.kt b/app/src/test/kotlin/com/android/messaging/ui/common/components/composer/MessageComposeBarInputTypeTest.kt index d41cbef1e..73c474221 100644 --- a/app/src/test/kotlin/com/android/messaging/ui/common/components/composer/MessageComposeBarInputTypeTest.kt +++ b/app/src/test/kotlin/com/android/messaging/ui/common/components/composer/MessageComposeBarInputTypeTest.kt @@ -47,7 +47,19 @@ internal class MessageComposeBarInputTypeTest { assertTrue(inputType and InputType.TYPE_TEXT_FLAG_MULTI_LINE != 0) } + @Test + fun messageComposeField_withImeSend_offersSendWhileEnterStillAddsANewLine() { + val imeOptions = focusedFieldEditorInfo(onImeSend = {}).imeOptions + + assertEquals(EditorInfo.IME_ACTION_SEND, imeOptions and EditorInfo.IME_MASK_ACTION) + assertTrue(imeOptions and EditorInfo.IME_FLAG_NO_ENTER_ACTION != 0) + } + private fun focusedFieldInputType(): Int { + return focusedFieldEditorInfo().inputType + } + + private fun focusedFieldEditorInfo(onImeSend: (() -> Unit)? = null): EditorInfo { var editorInfo: EditorInfo? = null composeTestRule.setContent { @@ -62,6 +74,7 @@ internal class MessageComposeBarInputTypeTest { fieldStateDescription = null, fieldTestTag = MESSAGE_COMPOSE_FIELD_TEST_TAG, sendAction = {}, + onImeSend = onImeSend, ) } } @@ -75,7 +88,7 @@ internal class MessageComposeBarInputTypeTest { editorInfo != null } - return requireNotNull(editorInfo).inputType + return requireNotNull(editorInfo) } } diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/composer/mapper/ConversationComposerUiStateMapperImplTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/composer/mapper/ConversationComposerUiStateMapperImplTest.kt index fcb6ecaef..6584fbb23 100644 --- a/app/src/test/kotlin/com/android/messaging/ui/conversation/composer/mapper/ConversationComposerUiStateMapperImplTest.kt +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/composer/mapper/ConversationComposerUiStateMapperImplTest.kt @@ -11,6 +11,7 @@ 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.MmsConfig +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.ConversationDraftState @@ -102,6 +103,27 @@ internal class ConversationComposerUiStateMapperImplTest { assertFalse(uiState.isSendEnabled) } + @Test + fun map_disablesSendWhileAudioIsRecording() { + val uiState = mapper.map( + audioRecording = ConversationAudioRecordingUiState( + phase = ConversationAudioRecordingPhase.Recording, + ), + draftState = ConversationDraftState( + draft = ConversationDraft( + messageText = "Hello", + ), + ), + attachments = persistentListOf(), + composerAvailability = ConversationComposerAvailability.Editable, + subscriptions = persistentListOf(), + areSubscriptionsLoaded = true, + defaultSmsSubscriptionId = SubId(ParticipantData.DEFAULT_SELF_SUB_ID), + ) + + assertFalse(uiState.isSendEnabled) + } + @Test fun map_disablesSendAndAttachmentWhenDraftIsBusy() { val uiState = mapper.map( diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/composer/ui/ConversationComposeBarTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/composer/ui/ConversationComposeBarTest.kt index 635206d6f..38eaaa776 100644 --- a/app/src/test/kotlin/com/android/messaging/ui/conversation/composer/ui/ConversationComposeBarTest.kt +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/composer/ui/ConversationComposeBarTest.kt @@ -29,6 +29,7 @@ import androidx.compose.ui.test.onAllNodesWithTag import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performImeAction import androidx.compose.ui.test.performTextInput import androidx.compose.ui.test.performTouchInput import androidx.compose.ui.unit.dp @@ -258,6 +259,48 @@ class ConversationComposeBarTest { } } + @Test + fun keyboardSendAction_sendsTheMessage() { + var sendClicks = 0 + + setContent( + messageText = "Hello", + isSendActionEnabled = true, + onSendClick = { + sendClicks += 1 + }, + ) + + composeTestRule + .onNodeWithTag(CONVERSATION_TEXT_FIELD_TEST_TAG) + .performImeAction() + + composeTestRule.runOnIdle { + assertEquals(1, sendClicks) + } + } + + @Test + fun keyboardSendAction_doesNothingWhileTheSendButtonIsDisabled() { + var sendClicks = 0 + + setContent( + messageText = "Hello", + isSendActionEnabled = false, + onSendClick = { + sendClicks += 1 + }, + ) + + composeTestRule + .onNodeWithTag(CONVERSATION_TEXT_FIELD_TEST_TAG) + .performImeAction() + + composeTestRule.runOnIdle { + assertEquals(0, sendClicks) + } + } + @Test fun textField_canBeDisabled() { setContent( diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/messagedetails/MessageDetailsViewModelTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/messagedetails/MessageDetailsViewModelTest.kt index 0f051dcd1..87ccdad11 100644 --- a/app/src/test/kotlin/com/android/messaging/ui/conversation/messagedetails/MessageDetailsViewModelTest.kt +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/messagedetails/MessageDetailsViewModelTest.kt @@ -1,6 +1,7 @@ package com.android.messaging.ui.conversation.messagedetails import android.content.ClipboardManager +import android.net.Uri import androidx.lifecycle.SavedStateHandle import com.android.messaging.data.appsettings.repository.AppSettingsRepository import com.android.messaging.data.conversation.model.ConversationId @@ -9,14 +10,19 @@ import com.android.messaging.data.conversation.model.message.ConversationMessage import com.android.messaging.data.conversation.model.message.ConversationMessageDetailsResult import com.android.messaging.data.conversation.repository.ConversationsRepository import com.android.messaging.datamodel.data.ConversationMessageData +import com.android.messaging.domain.media.usecase.ResolveAudioDurationMillis import com.android.messaging.testutil.MainDispatcherRule import com.android.messaging.ui.conversation.messagedetails.mapper.MessageDetailsUiStateMapper import com.android.messaging.ui.conversation.messagedetails.model.MessageDetailsUiState +import com.android.messaging.ui.conversation.messages.model.message.ConversationMessagePartUiModel +import com.android.messaging.ui.conversation.messages.model.message.ConversationMessageUiModel import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every import io.mockk.mockk import io.mockk.verify +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest @@ -24,8 +30,11 @@ import org.junit.Assert.assertEquals import org.junit.Assert.assertThrows import org.junit.Rule import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner @OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) internal class MessageDetailsViewModelTest { @get:Rule @@ -36,6 +45,7 @@ internal class MessageDetailsViewModelTest { coEvery { isYouTubeLinkPreviewsEnabled() } returns false } private val messageDetailsUiStateMapper = mockk() + private val resolveAudioDurationMillis = mockk() private val clipboardManager = mockk() @Test @@ -49,7 +59,11 @@ internal class MessageDetailsViewModelTest { fun uiState_loadsMessageDetailsFromSeededIdsAndExposesMappedState() = runTest { val message = mockk() val details = mockk() - val content = mockk() + val content = MessageDetailsUiState.Content( + preview = messageUiModel(), + details = details, + youTubeLinkPreviewsEnabled = false, + ) coEvery { conversationsRepository.getMessageDetails( @@ -88,6 +102,55 @@ internal class MessageDetailsViewModelTest { } } + @Test + fun uiState_resolvesTheLengthOfAnAudioClipInThePreview() = runTest { + val message = mockk() + val details = mockk() + val audio = ConversationMessagePartUiModel.Attachment.Audio( + text = null, + contentType = "audio/mp4", + contentUri = Uri.parse(AUDIO_CONTENT_URI), + width = 0, + height = 0, + ) + + coEvery { + conversationsRepository.getMessageDetails( + conversationId = ConversationId("c"), + messageId = MessageId("m"), + ) + } returns ConversationMessageDetailsResult( + message = message, + details = details, + ) + every { + messageDetailsUiStateMapper.map( + message = message, + details = details, + youTubeLinkPreviewsEnabled = false, + ) + } returns MessageDetailsUiState.Content( + preview = messageUiModel(parts = persistentListOf(audio)), + details = details, + youTubeLinkPreviewsEnabled = false, + ) + coEvery { resolveAudioDurationMillis(contentUri = AUDIO_CONTENT_URI) } returns 63_000L + + val viewModel = createViewModel() + advanceUntilIdle() + + assertEquals( + MessageDetailsUiState.Content( + preview = messageUiModel( + parts = persistentListOf(audio.copy(durationMillis = 63_000L)), + ), + details = details, + youTubeLinkPreviewsEnabled = false, + ), + viewModel.uiState.value, + ) + } + @Test fun uiState_whenMapperReturnsUnavailable_exposesUnavailable() = runTest { coEvery { @@ -134,8 +197,46 @@ internal class MessageDetailsViewModelTest { conversationsRepository = conversationsRepository, appSettingsRepository = appSettingsRepository, messageDetailsUiStateMapper = messageDetailsUiStateMapper, + resolveAudioDurationMillis = resolveAudioDurationMillis, clipboardManager = clipboardManager, savedStateHandle = savedStateHandle, ) } + + private fun messageUiModel( + parts: ImmutableList = persistentListOf(), + ): ConversationMessageUiModel { + return ConversationMessageUiModel( + messageId = MessageId("m"), + conversationId = ConversationId("c"), + text = null, + parts = parts, + sentTimestamp = 1L, + receivedTimestamp = 1L, + displayTimestamp = 1L, + status = ConversationMessageUiModel.Status.Outgoing.Complete, + isIncoming = false, + senderDisplayName = null, + senderAvatarUri = null, + senderContactId = 0L, + senderContactLookupKey = null, + senderNormalizedDestination = null, + senderParticipantId = null, + selfParticipantId = null, + canClusterWithPrevious = false, + canClusterWithNext = false, + canCopyMessageToClipboard = false, + canDownloadMessage = false, + canForwardMessage = false, + canResendMessage = false, + canSaveAttachments = false, + mmsDownload = null, + mmsSubject = null, + protocol = ConversationMessageUiModel.Protocol.MMS, + ) + } + + private companion object { + private const val AUDIO_CONTENT_URI = "content://mms/part/1" + } } diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversationlist/delegate/ConversationListActionsDelegateImplTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversationlist/delegate/ConversationListActionsDelegateImplTest.kt index 4b48d01db..1b7e8227e 100644 --- a/app/src/test/kotlin/com/android/messaging/ui/conversationlist/delegate/ConversationListActionsDelegateImplTest.kt +++ b/app/src/test/kotlin/com/android/messaging/ui/conversationlist/delegate/ConversationListActionsDelegateImplTest.kt @@ -272,7 +272,7 @@ internal class ConversationListActionsDelegateImplTest { } @Test - fun delete_routesEachItemWithItsLatestMessageTimestamp() = runTest { + fun delete_routesAllItemsInOneBatchWithTheirLatestMessageTimestamps() = runTest { val harness = createHarness() harness.delegate.delete( @@ -283,10 +283,15 @@ internal class ConversationListActionsDelegateImplTest { ) verify(exactly = 1) { - harness.conversationsRepository.deleteConversation(ConversationId("a"), 5_000L) + harness.conversationsRepository.deleteConversations( + mapOf( + ConversationId("a") to 5_000L, + ConversationId("b") to 7_000L, + ), + ) } - verify(exactly = 1) { - harness.conversationsRepository.deleteConversation(ConversationId("b"), 7_000L) + verify(exactly = 0) { + harness.conversationsRepository.deleteConversation(any(), any()) } } diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversationpicker/ConversationPickerScreenTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversationpicker/ConversationPickerScreenTest.kt new file mode 100644 index 000000000..c7144b532 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversationpicker/ConversationPickerScreenTest.kt @@ -0,0 +1,88 @@ +package com.android.messaging.ui.conversationpicker + +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performImeAction +import com.android.messaging.ui.common.components.composer.MESSAGE_COMPOSE_FIELD_TEST_TAG +import com.android.messaging.ui.conversationpicker.model.ConversationPickerAction as Action +import com.android.messaging.ui.conversationpicker.model.ConversationPickerEffect as Effect +import com.android.messaging.ui.conversationpicker.model.ConversationPickerLabels +import com.android.messaging.ui.conversationpicker.model.ConversationPickerUiState as State +import com.android.messaging.ui.conversationpicker.model.DraftUiState +import com.android.messaging.ui.core.AppTheme +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.emptyFlow +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class ConversationPickerScreenTest { + + @get:Rule + val composeTestRule = createComposeRule() + + @Test + fun keyboardSendAction_sendsTheReviewedDraft() { + val screenModel = setReviewContent(isSendEnabled = true) + + composeTestRule.onNodeWithTag(MESSAGE_COMPOSE_FIELD_TEST_TAG).performImeAction() + + assertEquals(1, screenModel.actions.count { it == Action.SendClicked }) + } + + @Test + fun keyboardSendAction_doesNothingWhileTheSendButtonIsDisabled() { + val screenModel = setReviewContent(isSendEnabled = false) + + composeTestRule.onNodeWithTag(MESSAGE_COMPOSE_FIELD_TEST_TAG).performImeAction() + + assertEquals(0, screenModel.actions.count { it == Action.SendClicked }) + } + + private fun setReviewContent(isSendEnabled: Boolean): RecordingScreenModel { + val screenModel = RecordingScreenModel( + uiState = State( + draft = DraftUiState( + isLoading = false, + isReviewing = true, + text = "Hello", + ), + isSendEnabled = isSendEnabled, + ), + ) + composeTestRule.setContent { + AppTheme { + ConversationPickerScreen( + screenModel = screenModel, + isInitialDraftLoading = true, + initialDraft = null, + effectHandler = object : ConversationPickerEffectHandler { + override fun handle(effect: Effect) {} + }, + onNavigateBack = {}, + allowMultiSelect = true, + labels = ConversationPickerLabels.Share, + ) + } + } + return screenModel + } + + private class RecordingScreenModel( + uiState: State, + ) : ConversationPickerScreenModel { + val actions = mutableListOf() + + override val effects: Flow = emptyFlow() + override val uiState: StateFlow = MutableStateFlow(uiState) + + override fun onAction(action: Action) { + actions += action + } + } +} diff --git a/src/com/android/messaging/data/conversation/repository/ConversationsRepository.kt b/src/com/android/messaging/data/conversation/repository/ConversationsRepository.kt index 0c0ddf4f9..abdab917c 100644 --- a/src/com/android/messaging/data/conversation/repository/ConversationsRepository.kt +++ b/src/com/android/messaging/data/conversation/repository/ConversationsRepository.kt @@ -99,6 +99,8 @@ internal interface ConversationsRepository { fun deleteConversation(conversationId: ConversationId, cutoffTimestamp: Long) + fun deleteConversations(cutoffTimestampsByConversationId: Map) + suspend fun setConversationSelfId(conversationId: ConversationId, selfId: ParticipantId) } @@ -309,6 +311,14 @@ internal class ConversationsRepositoryImpl @Inject constructor( ) } + override fun deleteConversations(cutoffTimestampsByConversationId: Map) { + cutoffTimestampsByConversationId + .filterKeys(ConversationId::isNotBlank) + .mapKeys { (conversationId, _) -> conversationId.value } + .takeIf { it.isNotEmpty() } + ?.let(DeleteConversationAction::deleteConversations) + } + override suspend fun setConversationSelfId( conversationId: ConversationId, selfId: ParticipantId, diff --git a/src/com/android/messaging/datamodel/action/DeleteConversationAction.java b/src/com/android/messaging/datamodel/action/DeleteConversationAction.java index abf52b097..e6bb88ca2 100644 --- a/src/com/android/messaging/datamodel/action/DeleteConversationAction.java +++ b/src/com/android/messaging/datamodel/action/DeleteConversationAction.java @@ -42,6 +42,7 @@ import java.util.ArrayList; import java.util.List; +import java.util.Map; /** * Action used to delete a conversation. @@ -94,6 +95,16 @@ public static void deleteConversation(final String conversationId, final long cu action.start(); } + public static void deleteConversations( + final Map cutoffTimestampsByConversationId) { + final TargetConversation[] conversations = cutoffTimestampsByConversationId.entrySet() + .stream() + .map(entry -> new TargetConversation(entry.getKey(), entry.getValue())) + .toArray(TargetConversation[]::new); + final DeleteConversationAction action = new DeleteConversationAction(conversations); + action.start(); + } + private static final String KEY_CONVERSATIONS = "conversations"; private DeleteConversationAction(final TargetConversation[] conversations) { @@ -175,7 +186,9 @@ private boolean deleteConversationInternal(DatabaseWrapper db, final TargetConve if (count > 0) { LogUtil.i(TAG, "DeleteConversationAction: Deleted telephony thread " + threadId + " (cutoffTimestamp = " + cutoffTimestamp + ")"); - } else { + } else if (MmsUtils.hasThreadMessages(threadId, cutoffTimestamp)) { + // Nothing deleted is only a failure if telephony kept the messages; a + // conversation that only holds a draft has none to delete. LogUtil.w(TAG, "DeleteConversationAction: Could not delete thread from " + "telephony: conversationId = " + conversationId + ", thread id = " + threadId); diff --git a/src/com/android/messaging/domain/blockedparticipants/usecase/DeleteDirectChats.kt b/src/com/android/messaging/domain/blockedparticipants/usecase/DeleteDirectChats.kt index 1a573c811..2208ca23a 100644 --- a/src/com/android/messaging/domain/blockedparticipants/usecase/DeleteDirectChats.kt +++ b/src/com/android/messaging/domain/blockedparticipants/usecase/DeleteDirectChats.kt @@ -11,8 +11,8 @@ internal interface DeleteDirectChats { internal class DeleteDirectChatsImpl @Inject constructor() : DeleteDirectChats { override operator fun invoke(conversationIds: List) { - conversationIds.forEach { conversationId -> - DeleteConversationAction.deleteConversation(conversationId.value, Long.MAX_VALUE) - } + conversationIds + .associate { conversationId -> conversationId.value to Long.MAX_VALUE } + .let(DeleteConversationAction::deleteConversations) } } diff --git a/src/com/android/messaging/sms/MmsUtils.java b/src/com/android/messaging/sms/MmsUtils.java index 9cb3bd01e..a77c8b1b2 100644 --- a/src/com/android/messaging/sms/MmsUtils.java +++ b/src/com/android/messaging/sms/MmsUtils.java @@ -2319,6 +2319,33 @@ public static int deleteThread(final long threadId, final long cutOffTimestampIn } } + /** + * Whether a thread still has SMS or MMS messages up to the cutoff, i.e. the messages + * {@link #deleteThread} would delete + */ + public static boolean hasThreadMessages( + final long threadId, + final long cutOffTimestampInMillis + ) { + try (Cursor cursor = queryThreadMessageIds(threadId, cutOffTimestampInMillis)) { + return cursor == null || cursor.getCount() > 0; + } + } + + private static Cursor queryThreadMessageIds( + final long threadId, + final long cutOffTimestampInMillis + ) { + final ContentResolver resolver = Factory.get().getApplicationContext().getContentResolver(); + final Uri threadUri = ContentUris.withAppendedId(Telephony.Threads.CONTENT_URI, threadId); + final String[] projection = new String[] { Sms._ID }; + // MmsSmsProvider drops selectionArgs for thread queries, so a "?" would compare to NULL + final String selection = Sms.DATE + "<=" + cutOffTimestampInMillis; + + return resolver.query(threadUri, projection, selection, null /* selectionArgs */, + null /* sortOrder */); + } + /** * Delete single SMS and MMS message * diff --git a/src/com/android/messaging/ui/common/components/composer/MessageComposeBar.kt b/src/com/android/messaging/ui/common/components/composer/MessageComposeBar.kt index cf76d5240..ebeb6e877 100644 --- a/src/com/android/messaging/ui/common/components/composer/MessageComposeBar.kt +++ b/src/com/android/messaging/ui/common/components/composer/MessageComposeBar.kt @@ -1,5 +1,6 @@ package com.android.messaging.ui.common.components.composer +import android.view.inputmethod.EditorInfo import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -13,6 +14,7 @@ import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.Send @@ -30,12 +32,15 @@ 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.platform.InterceptPlatformTextInput +import androidx.compose.ui.platform.PlatformTextInputInterceptor import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.stateDescription +import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.tooling.preview.PreviewLightDark @@ -49,6 +54,20 @@ private val ComposeBarHorizontalPadding = 12.dp private val ComposeBarItemSpacing = 8.dp private val SendButtonSize = 56.dp +// Compose drops IME_FLAG_NO_ENTER_ACTION from a multi-line field once it has an IME action, which +// would turn Enter into Send. Put it back so Enter keeps inserting a new line while voice typing +// and accessibility services can still send, like the legacy actionSend|flagNoEnterAction. +private val EnterInsertsNewLineInterceptor = PlatformTextInputInterceptor { request, nextHandler -> + nextHandler.startInputMethod( + request = { outAttributes -> + request.createInputConnection(outAttributes).also { + outAttributes.imeOptions = + outAttributes.imeOptions or EditorInfo.IME_FLAG_NO_ENTER_ACTION + } + }, + ) +} + @Composable internal fun MessageComposeBar( text: String, @@ -65,6 +84,7 @@ internal fun MessageComposeBar( leadingContent: (@Composable () -> Unit)? = null, trailingContent: (@Composable () -> Unit)? = null, fieldOverlay: (@Composable BoxScope.() -> Unit)? = null, + onImeSend: (() -> Unit)? = null, ) { Column( modifier = modifier.fillMaxWidth(), @@ -94,6 +114,7 @@ internal fun MessageComposeBar( leadingContent = leadingContent, trailingContent = trailingContent, overlay = fieldOverlay, + onImeSend = onImeSend, ) sendAction() @@ -114,6 +135,7 @@ private fun MessageComposeField( leadingContent: (@Composable () -> Unit)?, trailingContent: (@Composable () -> Unit)?, overlay: (@Composable BoxScope.() -> Unit)?, + onImeSend: (() -> Unit)?, modifier: Modifier = Modifier, ) { val focusRequesterModifier = focusRequester @@ -146,29 +168,23 @@ private fun MessageComposeField( topContent?.invoke(this) Box { - TextField( - modifier = Modifier - .fillMaxWidth() - .then(focusRequesterModifier) - .testTag(testTag) - .heightIn(min = ComposeBarControlHeight) - .then(stateDescriptionModifier) - .then(contentHiddenModifier), - value = text, - onValueChange = onTextChange, - enabled = isEnabled, - shape = MaterialTheme.shapes.large, - colors = messageComposeFieldColors(), - placeholder = ::MessageComposePlaceholder, - leadingIcon = leadingContent, - trailingIcon = trailingContent, - minLines = 1, - maxLines = 4, - keyboardOptions = KeyboardOptions( - capitalization = KeyboardCapitalization.Sentences, - keyboardType = KeyboardType.ShortMessage, - ), - ) + InterceptPlatformTextInput(interceptor = EnterInsertsNewLineInterceptor) { + MessageComposeTextField( + modifier = Modifier + .fillMaxWidth() + .then(focusRequesterModifier) + .testTag(testTag) + .heightIn(min = ComposeBarControlHeight) + .then(stateDescriptionModifier) + .then(contentHiddenModifier), + text = text, + onTextChange = onTextChange, + isEnabled = isEnabled, + leadingContent = leadingContent, + trailingContent = trailingContent, + onImeSend = onImeSend, + ) + } overlay?.invoke(this) } @@ -176,6 +192,40 @@ private fun MessageComposeField( } } +@Composable +private fun MessageComposeTextField( + text: String, + onTextChange: (String) -> Unit, + isEnabled: Boolean, + leadingContent: (@Composable () -> Unit)?, + trailingContent: (@Composable () -> Unit)?, + onImeSend: (() -> Unit)?, + modifier: Modifier = Modifier, +) { + TextField( + modifier = modifier, + value = text, + onValueChange = onTextChange, + enabled = isEnabled, + shape = MaterialTheme.shapes.large, + colors = messageComposeFieldColors(), + placeholder = ::MessageComposePlaceholder, + leadingIcon = leadingContent, + trailingIcon = trailingContent, + minLines = 1, + maxLines = 4, + keyboardOptions = KeyboardOptions( + capitalization = KeyboardCapitalization.Sentences, + keyboardType = KeyboardType.ShortMessage, + imeAction = when (onImeSend) { + null -> ImeAction.Default + else -> ImeAction.Send + }, + ), + keyboardActions = KeyboardActions(onSend = { onImeSend?.invoke() }), + ) +} + @Composable private fun MessageComposePlaceholder() { Text( 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 72ace783f..9bf5ddb45 100644 --- a/src/com/android/messaging/ui/conversation/composer/mapper/ConversationComposerUiStateMapper.kt +++ b/src/com/android/messaging/ui/conversation/composer/mapper/ConversationComposerUiStateMapper.kt @@ -53,10 +53,10 @@ internal class ConversationComposerUiStateMapperImpl @Inject constructor() : val isPrimaryActionAvailable = isComposerInteractionAvailable && draftState.pendingAttachments.isEmpty() - val shouldShowRecordAction = !hasWorkingDraft && - audioRecording.phase == ConversationAudioRecordingPhase.Idle + val isAudioRecordingIdle = audioRecording.phase == ConversationAudioRecordingPhase.Idle + val shouldShowRecordAction = !hasWorkingDraft && isAudioRecordingIdle - val isSendEnabled = hasWorkingDraft && isPrimaryActionAvailable + val isSendEnabled = hasWorkingDraft && isPrimaryActionAvailable && isAudioRecordingIdle val simSelector = buildSimSelectorUiState( subscriptions = subscriptions, 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 5be3ad6ab..c978e6209 100644 --- a/src/com/android/messaging/ui/conversation/composer/ui/ConversationComposeBar.kt +++ b/src/com/android/messaging/ui/conversation/composer/ui/ConversationComposeBar.kt @@ -296,6 +296,7 @@ internal fun ConversationComposeInputContent( onAudioRecordingFinish = onAudioRecordingFinish, ) }, + onImeSend = { if (isSendActionEnabled) onSendClick() }, ) } diff --git a/src/com/android/messaging/ui/conversation/messagedetails/MessageDetailsViewModel.kt b/src/com/android/messaging/ui/conversation/messagedetails/MessageDetailsViewModel.kt index 72226d814..03ac8c45f 100644 --- a/src/com/android/messaging/ui/conversation/messagedetails/MessageDetailsViewModel.kt +++ b/src/com/android/messaging/ui/conversation/messagedetails/MessageDetailsViewModel.kt @@ -9,10 +9,13 @@ import com.android.messaging.data.appsettings.repository.AppSettingsRepository import com.android.messaging.data.conversation.model.ConversationId import com.android.messaging.data.conversation.model.MessageId import com.android.messaging.data.conversation.repository.ConversationsRepository +import com.android.messaging.domain.media.usecase.ResolveAudioDurationMillis import com.android.messaging.ui.conversation.messagedetails.mapper.MessageDetailsUiStateMapper import com.android.messaging.ui.conversation.messagedetails.model.MessageDetailsUiState as State +import com.android.messaging.ui.conversation.messages.mapper.withAudioDurations import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject +import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -32,6 +35,7 @@ internal class MessageDetailsViewModel @Inject constructor( private val conversationsRepository: ConversationsRepository, private val appSettingsRepository: AppSettingsRepository, private val messageDetailsUiStateMapper: MessageDetailsUiStateMapper, + private val resolveAudioDurationMillis: ResolveAudioDurationMillis, private val clipboardManager: ClipboardManager, savedStateHandle: SavedStateHandle, ) : ViewModel(), @@ -68,10 +72,27 @@ internal class MessageDetailsViewModel @Inject constructor( messageId = messageId, ) - return messageDetailsUiStateMapper.map( - message = result?.message, - details = result?.details, - youTubeLinkPreviewsEnabled = appSettingsRepository.isYouTubeLinkPreviewsEnabled(), - ) + return messageDetailsUiStateMapper + .map( + message = result?.message, + details = result?.details, + youTubeLinkPreviewsEnabled = appSettingsRepository.isYouTubeLinkPreviewsEnabled(), + ) + .withResolvedAudioDurations() + } + + private suspend fun State.withResolvedAudioDurations(): State { + return when (this) { + is State.Content -> { + copy( + preview = withAudioDurations( + messages = persistentListOf(preview), + resolveAudioDurationMillis = resolveAudioDurationMillis, + ).single(), + ) + } + + else -> this + } } } diff --git a/src/com/android/messaging/ui/conversation/messages/delegate/ConversationMessagesDelegate.kt b/src/com/android/messaging/ui/conversation/messages/delegate/ConversationMessagesDelegate.kt index 425da7e7d..790528622 100644 --- a/src/com/android/messaging/ui/conversation/messages/delegate/ConversationMessagesDelegate.kt +++ b/src/com/android/messaging/ui/conversation/messages/delegate/ConversationMessagesDelegate.kt @@ -12,6 +12,7 @@ import com.android.messaging.domain.media.usecase.ResolveAudioDurationMillis 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.mapper.withAudioDurations 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 @@ -21,9 +22,6 @@ import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.async -import kotlinx.coroutines.awaitAll -import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -185,7 +183,12 @@ internal class ConversationMessagesDelegateImpl @Inject constructor( .mapNotNull(conversationMessageUiModelMapper::map) .toImmutableList() } - .map(::withAudioDurations) + .map { messages -> + withAudioDurations( + messages = messages, + resolveAudioDurationMillis = resolveAudioDurationMillis, + ) + } .flatMapLatest { messages -> observeMessagesWithVCardMetadata( messages = messages, @@ -208,54 +211,6 @@ internal class ConversationMessagesDelegateImpl @Inject constructor( .distinctUntilChanged() } - private suspend fun withAudioDurations( - messages: ImmutableList, - ): ImmutableList { - val audioContentUris = messages - .asSequence() - .flatMap(ConversationMessageUiModel::parts) - .filterIsInstance() - .mapNotNullTo(mutableSetOf()) { audioPart -> audioPart.contentUri?.toString() } - - if (audioContentUris.isEmpty()) { - return messages - } - - val durationsByContentUri = coroutineScope { - audioContentUris - .map { contentUri -> - async { contentUri to resolveAudioDurationMillis(contentUri) } - } - .awaitAll() - .toMap() - } - - return messages - .map { message -> - val parts = message.parts.map { part -> - when (part) { - is ConversationMessagePartUiModel.Attachment.Audio -> { - part.copy( - durationMillis = part - .contentUri - ?.toString() - ?.let(durationsByContentUri::get) - ?: part.durationMillis, - ) - } - - else -> part - } - } - - when (parts) { - message.parts -> message - else -> message.copy(parts = parts.toImmutableList()) - } - } - .toImmutableList() - } - private fun observeMessagesWithVCardMetadata( messages: List, ): Flow> { diff --git a/src/com/android/messaging/ui/conversation/messages/mapper/ConversationMessageAudioDurations.kt b/src/com/android/messaging/ui/conversation/messages/mapper/ConversationMessageAudioDurations.kt new file mode 100644 index 000000000..babc83176 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/messages/mapper/ConversationMessageAudioDurations.kt @@ -0,0 +1,84 @@ +package com.android.messaging.ui.conversation.messages.mapper + +import com.android.messaging.domain.media.usecase.ResolveAudioDurationMillis +import com.android.messaging.ui.conversation.messages.model.message.ConversationMessagePartUiModel +import com.android.messaging.ui.conversation.messages.model.message.ConversationMessageUiModel +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope + +internal suspend fun withAudioDurations( + messages: ImmutableList, + resolveAudioDurationMillis: ResolveAudioDurationMillis, +): ImmutableList { + val audioContentUris = audioContentUris(messages = messages) + + if (audioContentUris.isEmpty()) { + return messages + } + + val durationsByContentUri = resolveDurations( + contentUris = audioContentUris, + resolveAudioDurationMillis = resolveAudioDurationMillis, + ) + + return messages + .map { message -> + message.withDurationsFrom(durationsByContentUri = durationsByContentUri) + } + .toImmutableList() +} + +private fun audioContentUris(messages: List): Set { + return messages + .asSequence() + .flatMap(ConversationMessageUiModel::parts) + .filterIsInstance() + .mapNotNullTo(mutableSetOf()) { audioPart -> audioPart.contentUri?.toString() } +} + +private suspend fun resolveDurations( + contentUris: Set, + resolveAudioDurationMillis: ResolveAudioDurationMillis, +): Map { + return coroutineScope { + contentUris + .map { contentUri -> + async { contentUri to resolveAudioDurationMillis(contentUri) } + } + .awaitAll() + .toMap() + } +} + +private fun ConversationMessageUiModel.withDurationsFrom( + durationsByContentUri: Map, +): ConversationMessageUiModel { + val updatedParts = parts.map { part -> + part.withDurationFrom(durationsByContentUri = durationsByContentUri) + } + + return when (updatedParts) { + parts -> this + else -> copy(parts = updatedParts.toImmutableList()) + } +} + +private fun ConversationMessagePartUiModel.withDurationFrom( + durationsByContentUri: Map, +): ConversationMessagePartUiModel { + return when (this) { + is ConversationMessagePartUiModel.Attachment.Audio -> { + copy( + durationMillis = contentUri + ?.toString() + ?.let(durationsByContentUri::get) + ?: durationMillis, + ) + } + + else -> this + } +} diff --git a/src/com/android/messaging/ui/conversationlist/common/item/ConversationListItemRow.kt b/src/com/android/messaging/ui/conversationlist/common/item/ConversationListItemRow.kt index 24d539ea0..3a6607e75 100644 --- a/src/com/android/messaging/ui/conversationlist/common/item/ConversationListItemRow.kt +++ b/src/com/android/messaging/ui/conversationlist/common/item/ConversationListItemRow.kt @@ -254,7 +254,7 @@ private fun itemContainerColor(item: ConversationListItemUiModel): Color { private fun itemUnreadFontWeight(item: ConversationListItemUiModel): FontWeight { return when { - item.isUnread -> FontWeight.Medium + item.isUnread -> FontWeight.Bold else -> FontWeight.Normal } } diff --git a/src/com/android/messaging/ui/conversationlist/delegate/ConversationListActionsDelegate.kt b/src/com/android/messaging/ui/conversationlist/delegate/ConversationListActionsDelegate.kt index b859c7d34..90ebf9a21 100644 --- a/src/com/android/messaging/ui/conversationlist/delegate/ConversationListActionsDelegate.kt +++ b/src/com/android/messaging/ui/conversationlist/delegate/ConversationListActionsDelegate.kt @@ -88,12 +88,11 @@ internal class ConversationListActionsDelegateImpl @Inject constructor( } override fun delete(items: List) { - items.forEach { item -> - conversationsRepository.deleteConversation( - conversationId = item.conversationId, - cutoffTimestamp = item.latestMessage.timestamp, - ) - } + items + .associate { item -> + item.conversationId to item.latestMessage.timestamp + } + .let(conversationsRepository::deleteConversations) } override fun snooze( diff --git a/src/com/android/messaging/ui/conversationpicker/ConversationPickerScreen.kt b/src/com/android/messaging/ui/conversationpicker/ConversationPickerScreen.kt index 81f6d5b04..21571b4ff 100644 --- a/src/com/android/messaging/ui/conversationpicker/ConversationPickerScreen.kt +++ b/src/com/android/messaging/ui/conversationpicker/ConversationPickerScreen.kt @@ -676,6 +676,7 @@ private fun PickerReviewComposeBar( onClick = { onAction(Action.SendClicked) }, ) }, + onImeSend = { if (uiState.isSendEnabled) onAction(Action.SendClicked) }, ) }