diff --git a/app/src/androidTest/kotlin/com/android/messaging/ui/appsettings/privacy/ui/PrivacySettingsScreenTest.kt b/app/src/androidTest/kotlin/com/android/messaging/ui/appsettings/privacy/ui/PrivacySettingsScreenTest.kt index ea1facb58..5d5eb0e34 100644 --- a/app/src/androidTest/kotlin/com/android/messaging/ui/appsettings/privacy/ui/PrivacySettingsScreenTest.kt +++ b/app/src/androidTest/kotlin/com/android/messaging/ui/appsettings/privacy/ui/PrivacySettingsScreenTest.kt @@ -1,13 +1,19 @@ package com.android.messaging.ui.appsettings.privacy.ui import androidx.activity.ComponentActivity +import androidx.annotation.StringRes +import androidx.compose.ui.test.SemanticsMatcher import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertIsOff +import androidx.compose.ui.test.assertIsSelected +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.isSelectable import androidx.compose.ui.test.isToggleable import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import com.android.messaging.R +import com.android.messaging.data.appsettings.model.LinkOpeningMode import com.android.messaging.ui.appsettings.general.model.AppSettingsAction as Action import com.android.messaging.ui.appsettings.general.model.AppSettingsUiState import com.android.messaging.ui.core.AppTheme @@ -58,11 +64,58 @@ class PrivacySettingsScreenTest { } } - private fun setContent() { + @Test + fun linkOpeningItem_showsCurrentMode() { + setContent(appSettings = AppSettingsUiState(linkOpeningMode = LinkOpeningMode.DoNotOpen)) + + composeTestRule.onNodeWithText(string(R.string.link_opening_pref_title)) + .assertIsDisplayed() + composeTestRule.onNodeWithText(string(R.string.link_opening_off)).assertIsDisplayed() + } + + @Test + fun linkOpeningDialog_selectsCurrentModeAndConfirmsNewMode() { + setContent() + + composeTestRule.onNodeWithText(string(R.string.link_opening_pref_title)).performClick() + composeTestRule.onNode(linkOpeningOption(titleResId = R.string.link_opening_ask)) + .assertIsSelected() + composeTestRule.onNode(linkOpeningOption(titleResId = R.string.link_opening_open)) + .performClick() + composeTestRule.onNodeWithText(string(android.R.string.ok)).performClick() + + verify(exactly = 1) { + onAction(Action.LinkOpeningModeChanged(LinkOpeningMode.OpenOnTap)) + } + composeTestRule.onNodeWithText(string(android.R.string.ok)).assertDoesNotExist() + } + + @Test + fun linkOpeningDialog_cancelKeepsMode() { + setContent() + + composeTestRule.onNodeWithText(string(R.string.link_opening_pref_title)).performClick() + composeTestRule.onNode(linkOpeningOption(titleResId = R.string.link_opening_off)) + .performClick() + composeTestRule.onNodeWithText(string(android.R.string.cancel)).performClick() + + verify(exactly = 0) { onAction(any()) } + composeTestRule.onNodeWithText(string(android.R.string.cancel)).assertDoesNotExist() + } + + private fun linkOpeningOption(@StringRes titleResId: Int): SemanticsMatcher { + return isSelectable() and hasText(string(titleResId)) + } + + private fun string(@StringRes resId: Int): String { + return composeTestRule.activity.getString(resId) + } + + private fun setContent(appSettings: AppSettingsUiState = AppSettingsUiState()) { composeTestRule.setContent { AppTheme { PrivacySettingsScreen( - appSettings = AppSettingsUiState(), + appSettings = appSettings, onAction = onAction, onNavigateBack = {}, ) diff --git a/app/src/sharedTest/kotlin/com/android/messaging/ui/conversation/messages/ui/attachment/rendering/BaseConversationMessageAttachmentRenderingTest.kt b/app/src/sharedTest/kotlin/com/android/messaging/ui/conversation/messages/ui/attachment/rendering/BaseConversationMessageAttachmentRenderingTest.kt index daff21980..6a87dbc08 100644 --- a/app/src/sharedTest/kotlin/com/android/messaging/ui/conversation/messages/ui/attachment/rendering/BaseConversationMessageAttachmentRenderingTest.kt +++ b/app/src/sharedTest/kotlin/com/android/messaging/ui/conversation/messages/ui/attachment/rendering/BaseConversationMessageAttachmentRenderingTest.kt @@ -2,6 +2,7 @@ package com.android.messaging.ui.conversation.messages.ui.attachment.rendering import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.width +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.platform.testTag @@ -12,6 +13,7 @@ import androidx.compose.ui.test.longClick import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.performTouchInput import androidx.compose.ui.unit.Dp +import com.android.messaging.data.appsettings.model.LinkOpeningMode 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 @@ -21,6 +23,7 @@ import com.android.messaging.ui.conversation.messages.ui.attachment.Conversation import com.android.messaging.ui.conversation.messages.ui.attachment.ConversationMessageAttachments import com.android.messaging.ui.conversation.messages.ui.attachment.ConversationStandaloneVisualAttachment import com.android.messaging.ui.conversation.messages.ui.attachment.rememberConversationInlineAudioAttachmentColors +import com.android.messaging.ui.conversation.messages.ui.text.LocalConversationMessageLinkOpeningMode import com.android.messaging.ui.core.AppTheme import io.mockk.clearAllMocks import io.mockk.mockk @@ -50,23 +53,28 @@ internal abstract class BaseConversationMessageAttachmentRenderingTest { isSelectionMode: Boolean = false, useStandaloneAudioAttachmentBackground: Boolean = false, width: Dp = ATTACHMENT_WIDTH, + linkOpeningMode: LinkOpeningMode = LinkOpeningMode.AskBeforeOpening, ) { composeTestRule.setContent { AppTheme { - ConversationMessageAttachments( - modifier = Modifier - .width(width = width) - .testTag(tag = MESSAGE_ATTACHMENTS_TAG), - attachmentSections = attachmentSections, - hasTextAboveVisualAttachments = hasTextAboveVisualAttachments, - hasTextBelowVisualAttachments = hasTextBelowVisualAttachments, - isIncoming = isIncoming, - isSelectionMode = isSelectionMode, - useStandaloneAudioAttachmentBg = useStandaloneAudioAttachmentBackground, - onAttachmentClick = onAttachmentClick, - onExternalUriClick = onExternalUriClick, - onMessageLongClick = onMessageLongClick, - ) + CompositionLocalProvider( + LocalConversationMessageLinkOpeningMode provides linkOpeningMode, + ) { + ConversationMessageAttachments( + modifier = Modifier + .width(width = width) + .testTag(tag = MESSAGE_ATTACHMENTS_TAG), + attachmentSections = attachmentSections, + hasTextAboveVisualAttachments = hasTextAboveVisualAttachments, + hasTextBelowVisualAttachments = hasTextBelowVisualAttachments, + isIncoming = isIncoming, + isSelectionMode = isSelectionMode, + useStandaloneAudioAttachmentBg = useStandaloneAudioAttachmentBackground, + onAttachmentClick = onAttachmentClick, + onExternalUriClick = onExternalUriClick, + onMessageLongClick = onMessageLongClick, + ) + } } } } diff --git a/app/src/sharedTest/kotlin/com/android/messaging/ui/conversation/messages/ui/message/rendering/BaseConversationMessageRenderingTest.kt b/app/src/sharedTest/kotlin/com/android/messaging/ui/conversation/messages/ui/message/rendering/BaseConversationMessageRenderingTest.kt index 686c2a182..d10c3eff6 100644 --- a/app/src/sharedTest/kotlin/com/android/messaging/ui/conversation/messages/ui/message/rendering/BaseConversationMessageRenderingTest.kt +++ b/app/src/sharedTest/kotlin/com/android/messaging/ui/conversation/messages/ui/message/rendering/BaseConversationMessageRenderingTest.kt @@ -18,6 +18,7 @@ import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performSemanticsAction import androidx.compose.ui.test.performTouchInput import androidx.core.net.toUri +import com.android.messaging.data.appsettings.model.LinkOpeningMode import com.android.messaging.data.conversation.model.MessageId import com.android.messaging.data.conversation.model.ParticipantId import com.android.messaging.datamodel.data.ParticipantData @@ -32,6 +33,7 @@ import com.android.messaging.ui.conversation.messages.ui.message.ConversationMes import com.android.messaging.ui.conversation.messages.ui.message.ConversationMessageAvatar import com.android.messaging.ui.conversation.messages.ui.message.ConversationMmsDownloadBody import com.android.messaging.ui.conversation.messages.ui.text.ConversationTextSelectionHost +import com.android.messaging.ui.conversation.messages.ui.text.LocalConversationMessageLinkOpeningMode import com.android.messaging.ui.conversation.screen.model.ConversationMessageAction import com.android.messaging.ui.core.AppTheme import io.mockk.clearAllMocks @@ -70,12 +72,14 @@ internal abstract class BaseConversationMessageRenderingTest { showIncomingParticipantIdentity: Boolean = true, simDisplayName: String? = null, textContextMenuToolbar: TextContextMenuProvider? = null, + linkOpeningMode: LinkOpeningMode = LinkOpeningMode.AskBeforeOpening, ) { composeTestRule.setContent { AppTheme { CompositionLocalProvider( LocalTextContextMenuToolbarProvider provides (textContextMenuToolbar ?: LocalTextContextMenuToolbarProvider.current), + LocalConversationMessageLinkOpeningMode provides linkOpeningMode, ) { ConversationTextSelectionHost( conversationId = message.conversationId, diff --git a/app/src/test/kotlin/com/android/messaging/data/appsettings/repository/appsettingsrepository/AppSettingsRepositoryImplTest.kt b/app/src/test/kotlin/com/android/messaging/data/appsettings/repository/appsettingsrepository/AppSettingsRepositoryImplTest.kt index f5d54c76c..1f4632ebc 100644 --- a/app/src/test/kotlin/com/android/messaging/data/appsettings/repository/appsettingsrepository/AppSettingsRepositoryImplTest.kt +++ b/app/src/test/kotlin/com/android/messaging/data/appsettings/repository/appsettingsrepository/AppSettingsRepositoryImplTest.kt @@ -5,6 +5,7 @@ import android.content.res.Resources import com.android.messaging.Factory import com.android.messaging.R import com.android.messaging.data.appsettings.model.AppBooleanPref +import com.android.messaging.data.appsettings.model.LinkOpeningMode import com.android.messaging.data.appsettings.repository.AppSettingsRepositoryImpl import com.android.messaging.data.debug.DebugFeaturesProvider import com.android.messaging.datamodel.data.ParticipantData @@ -73,6 +74,10 @@ internal class AppSettingsRepositoryImplTest { every { resources.getBoolean(R.bool.youtube_link_previews_pref_default) } returns YOUTUBE_LINK_PREVIEWS_DEFAULT + every { context.getString(R.string.link_opening_pref_key) } returns LINK_OPENING_PREF_KEY + every { + context.getString(R.string.link_opening_pref_default) + } returns LINK_OPENING_DEFAULT every { resources.getBoolean(R.bool.send_sound_pref_default) } returns SEND_SOUND_DEFAULT every { @@ -108,6 +113,9 @@ internal class AppSettingsRepositoryImplTest { YOUTUBE_LINK_PREVIEWS_DEFAULT, ) } returns true + every { + appPrefs.getString(LINK_OPENING_PREF_KEY, LINK_OPENING_DEFAULT) + } returns LinkOpeningMode.OpenOnTap.prefValue val result = createRepository( ioDispatcher = UnconfinedTestDispatcher(testScheduler), @@ -118,6 +126,7 @@ internal class AppSettingsRepositoryImplTest { assertFalse(result.sendSoundEnabled) assertTrue(result.inConversationSoundEnabled) assertTrue(result.youTubeLinkPreviewsEnabled) + assertEquals(LinkOpeningMode.OpenOnTap, result.linkOpeningMode) assertTrue(result.isDebugEnabled) assertTrue(result.dumpSmsEnabled) assertFalse(result.dumpMmsEnabled) @@ -224,6 +233,68 @@ internal class AppSettingsRepositoryImplTest { } } + @Test + fun getLinkOpeningMode_readsEveryStoredMode() { + runTest { + val repository = createRepository( + ioDispatcher = UnconfinedTestDispatcher(testScheduler), + ) + + LinkOpeningMode.entries.forEach { mode -> + every { + appPrefs.getString(LINK_OPENING_PREF_KEY, LINK_OPENING_DEFAULT) + } returns mode.prefValue + + assertEquals(mode, repository.getLinkOpeningMode()) + } + } + } + + @Test + fun getLinkOpeningMode_fallsBackToAskBeforeOpeningForUnknownStoredValue() { + runTest { + every { + appPrefs.getString(LINK_OPENING_PREF_KEY, LINK_OPENING_DEFAULT) + } returns "unknown" + + val result = createRepository( + ioDispatcher = UnconfinedTestDispatcher(testScheduler), + ).getLinkOpeningMode() + + assertEquals(LinkOpeningMode.AskBeforeOpening, result) + } + } + + @Test + fun getLinkOpeningMode_defaultsToAskBeforeOpeningFromResources() { + runTest { + every { appPrefs.getString(any(), any()) } answers { secondArg() } + + val result = AppSettingsRepositoryImpl( + context = RuntimeEnvironment.getApplication().applicationContext, + ioDispatcher = UnconfinedTestDispatcher(testScheduler), + debugFeaturesProvider = debugFeaturesProvider, + ).getLinkOpeningMode() + + assertEquals(LinkOpeningMode.AskBeforeOpening, result) + } + } + + @Test + fun setLinkOpeningMode_writesModePreferenceValue() { + runTest { + every { appPrefs.putString(any(), any()) } just runs + + createRepository( + ioDispatcher = UnconfinedTestDispatcher(testScheduler), + ).setLinkOpeningMode(mode = LinkOpeningMode.DoNotOpen) + + verify(exactly = 1) { + appPrefs.putString(LINK_OPENING_PREF_KEY, LinkOpeningMode.DoNotOpen.prefValue) + } + } + } + private fun createRepository(ioDispatcher: CoroutineDispatcher): AppSettingsRepositoryImpl { return AppSettingsRepositoryImpl( context = context, @@ -240,6 +311,8 @@ internal class AppSettingsRepositoryImplTest { private const val DUMP_SMS_PREF_KEY = "dump_sms" private const val IN_CONVERSATION_SOUND_DEFAULT = false private const val IN_CONVERSATION_SOUND_PREF_KEY = "in_conversation_sound" + private const val LINK_OPENING_DEFAULT = "ask" + private const val LINK_OPENING_PREF_KEY = "link_opening" private const val SEND_SOUND_DEFAULT = true private const val SEND_SOUND_PREF_KEY = "send_sound" private const val YOUTUBE_LINK_PREVIEWS_DEFAULT = false diff --git a/app/src/test/kotlin/com/android/messaging/ui/appsettings/general/AppSettingsViewModelTest.kt b/app/src/test/kotlin/com/android/messaging/ui/appsettings/general/AppSettingsViewModelTest.kt index 96a6d1a84..340a5978c 100644 --- a/app/src/test/kotlin/com/android/messaging/ui/appsettings/general/AppSettingsViewModelTest.kt +++ b/app/src/test/kotlin/com/android/messaging/ui/appsettings/general/AppSettingsViewModelTest.kt @@ -1,6 +1,7 @@ package com.android.messaging.ui.appsettings.general import app.cash.turbine.test +import com.android.messaging.data.appsettings.model.LinkOpeningMode import com.android.messaging.testutil.MainDispatcherRule import com.android.messaging.ui.appsettings.general.delegate.AppSettingsDelegate import com.android.messaging.ui.appsettings.general.model.AppSettingsAction as Action @@ -108,6 +109,20 @@ class AppSettingsViewModelTest { } } + @Test + fun onLinkOpeningModeChanged_delegatesToAppSettings() { + runTest(context = mainDispatcherRule.testDispatcher) { + val delegate = mockDelegate() + val viewModel = createViewModel(delegate = delegate) + + viewModel.onAction(Action.LinkOpeningModeChanged(mode = LinkOpeningMode.DoNotOpen)) + + verify(exactly = 1) { + delegate.onLinkOpeningModeChanged(mode = LinkOpeningMode.DoNotOpen) + } + } + } + @Test fun onDefaultSmsAppClick_whenDefault_emitsOpenManageDefaultApps() { runTest(context = mainDispatcherRule.testDispatcher) { diff --git a/app/src/test/kotlin/com/android/messaging/ui/appsettings/general/delegate/appsettingsdelegate/AppSettingsDelegatePreferenceTest.kt b/app/src/test/kotlin/com/android/messaging/ui/appsettings/general/delegate/appsettingsdelegate/AppSettingsDelegatePreferenceTest.kt index 6fe321cab..daebc1c20 100644 --- a/app/src/test/kotlin/com/android/messaging/ui/appsettings/general/delegate/appsettingsdelegate/AppSettingsDelegatePreferenceTest.kt +++ b/app/src/test/kotlin/com/android/messaging/ui/appsettings/general/delegate/appsettingsdelegate/AppSettingsDelegatePreferenceTest.kt @@ -1,6 +1,7 @@ package com.android.messaging.ui.appsettings.general.delegate.appsettingsdelegate import com.android.messaging.data.appsettings.model.AppBooleanPref +import com.android.messaging.data.appsettings.model.LinkOpeningMode import io.mockk.coVerify import io.mockk.coVerifyOrder import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -91,6 +92,25 @@ internal class AppSettingsDelegatePreferenceTest : BaseAppSettingsDelegateTest() } } + @Test + fun onLinkOpeningModeChanged_writesModeThenRefreshesState() { + runTest( + context = mainDispatcherRule.testDispatcher, + ) { + givenSecondLoadProducesReloadedState() + val delegate = createBoundDelegate() + + delegate.onLinkOpeningModeChanged(mode = LinkOpeningMode.DoNotOpen) + runCurrent() + + coVerifyOrder { + repository.setLinkOpeningMode(mode = LinkOpeningMode.DoNotOpen) + repository.getAppSettings() + } + assertEquals(reloadedState, delegate.state.value) + } + } + @Test fun booleanPrefChange_beforeBind_doesNotWriteOrLoad() { runTest( @@ -101,6 +121,7 @@ internal class AppSettingsDelegatePreferenceTest : BaseAppSettingsDelegateTest() delegate.onSendSoundChanged(enabled = false) delegate.onDumpSmsChanged(enabled = true) delegate.onDumpMmsChanged(enabled = true) + delegate.onLinkOpeningModeChanged(mode = LinkOpeningMode.OpenOnTap) runCurrent() coVerify(exactly = 0) { @@ -109,6 +130,7 @@ internal class AppSettingsDelegatePreferenceTest : BaseAppSettingsDelegateTest() enabled = any(), ) } + coVerify(exactly = 0) { repository.setLinkOpeningMode(mode = any()) } coVerify(exactly = 0) { repository.getAppSettings() } } } diff --git a/app/src/test/kotlin/com/android/messaging/ui/appsettings/general/delegate/appsettingsdelegate/BaseAppSettingsDelegateTest.kt b/app/src/test/kotlin/com/android/messaging/ui/appsettings/general/delegate/appsettingsdelegate/BaseAppSettingsDelegateTest.kt index 4c4949643..30e3e3508 100644 --- a/app/src/test/kotlin/com/android/messaging/ui/appsettings/general/delegate/appsettingsdelegate/BaseAppSettingsDelegateTest.kt +++ b/app/src/test/kotlin/com/android/messaging/ui/appsettings/general/delegate/appsettingsdelegate/BaseAppSettingsDelegateTest.kt @@ -57,6 +57,7 @@ internal abstract class BaseAppSettingsDelegateTest { enabled = any(), ) } just Runs + coEvery { repository.setLinkOpeningMode(mode = any()) } just Runs } protected fun createDelegate(): AppSettingsDelegateImpl { diff --git a/app/src/test/kotlin/com/android/messaging/ui/appsettings/general/mapper/appsettingsuistatemapper/AppSettingsUiStateMapperImplTest.kt b/app/src/test/kotlin/com/android/messaging/ui/appsettings/general/mapper/appsettingsuistatemapper/AppSettingsUiStateMapperImplTest.kt index 0229f220d..0ac19fab0 100644 --- a/app/src/test/kotlin/com/android/messaging/ui/appsettings/general/mapper/appsettingsuistatemapper/AppSettingsUiStateMapperImplTest.kt +++ b/app/src/test/kotlin/com/android/messaging/ui/appsettings/general/mapper/appsettingsuistatemapper/AppSettingsUiStateMapperImplTest.kt @@ -3,6 +3,7 @@ package com.android.messaging.ui.appsettings.general.mapper.appsettingsuistatema import android.content.Context import com.android.messaging.R import com.android.messaging.data.appsettings.model.AppSettings +import com.android.messaging.data.appsettings.model.LinkOpeningMode import com.android.messaging.ui.appsettings.general.mapper.AppSettingsUiStateMapperImpl import com.android.messaging.ui.appsettings.general.model.AppSettingsUiState import io.mockk.every @@ -30,6 +31,7 @@ internal class AppSettingsUiStateMapperImplTest { sendSoundEnabled = false, inConversationSoundEnabled = true, youTubeLinkPreviewsEnabled = true, + linkOpeningMode = LinkOpeningMode.DoNotOpen, isDebugEnabled = true, dumpSmsEnabled = true, dumpMmsEnabled = false, @@ -43,6 +45,7 @@ internal class AppSettingsUiStateMapperImplTest { sendSoundEnabled = false, inConversationSoundEnabled = true, youTubeLinkPreviewsEnabled = true, + linkOpeningMode = LinkOpeningMode.DoNotOpen, isDebugEnabled = true, dumpSmsEnabled = true, dumpMmsEnabled = false, diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/links/delegate/ConversationLinkDelegateImplTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/links/delegate/ConversationLinkDelegateImplTest.kt new file mode 100644 index 000000000..464226399 --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/links/delegate/ConversationLinkDelegateImplTest.kt @@ -0,0 +1,379 @@ +package com.android.messaging.ui.conversation.links.delegate + +import app.cash.turbine.test +import com.android.messaging.data.appsettings.model.LinkOpeningMode +import com.android.messaging.data.appsettings.repository.AppSettingsRepository +import com.android.messaging.data.conversation.model.ConversationId +import com.android.messaging.testutil.MainDispatcherRule +import com.android.messaging.testutil.TEST_CONVERSATION_ID as CONVERSATION_ID +import com.android.messaging.ui.conversation.links.model.ConversationLinkConfirmationUiState +import com.android.messaging.ui.conversation.screen.model.ConversationScreenEffect +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +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 ConversationLinkDelegateImplTest { + + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + private val appSettingsRepository = mockk() + + @Test + fun onExternalUriClicked_askBeforeOpening_showsConfirmationWithoutOpening() { + runTest(context = mainDispatcherRule.testDispatcher) { + givenLinkOpeningMode(mode = LinkOpeningMode.AskBeforeOpening) + val delegate = createBoundDelegate() + + delegate.effects.test { + delegate.onExternalUriClicked(uri = LINK_URI) + runCurrent() + + assertEquals(LINK_CONFIRMATION, delegate.state.value) + expectNoEvents() + } + } + } + + @Test + fun confirmation_webLinkWithUserInfo_showsRealHost() { + runTest(context = mainDispatcherRule.testDispatcher) { + val uri = "https://bank.example@attacker.example/login" + + assertEquals( + ConversationLinkConfirmationUiState( + uri = uri, + destination = "attacker.example", + showsFullUri = true, + ), + confirmationFor(uri = uri), + ) + } + } + + @Test + fun confirmation_phoneLink_showsDecodedNumberOnly() { + runTest(context = mainDispatcherRule.testDispatcher) { + assertEquals( + ConversationLinkConfirmationUiState( + uri = PHONE_URI, + destination = "+31 6 2222 3333", + showsFullUri = false, + ), + confirmationFor(uri = PHONE_URI), + ) + } + } + + @Test + fun confirmation_emailLink_showsDecodedAddressOnly() { + runTest(context = mainDispatcherRule.testDispatcher) { + assertEquals( + ConversationLinkConfirmationUiState( + uri = EMAIL_URI, + destination = "preview@example.com", + showsFullUri = false, + ), + confirmationFor(uri = EMAIL_URI), + ) + } + } + + @Test + fun confirmation_addressLink_showsDecodedQueryOnly() { + runTest(context = mainDispatcherRule.testDispatcher) { + assertEquals( + ConversationLinkConfirmationUiState( + uri = ADDRESS_URI, + destination = "1600 Amphitheatre Parkway, Mountain View, CA", + showsFullUri = false, + ), + confirmationFor(uri = ADDRESS_URI), + ) + } + } + + @Test + fun confirmation_linkWithoutReadableTarget_fallsBackToUri() { + runTest(context = mainDispatcherRule.testDispatcher) { + val uri = "tel:" + + assertEquals( + ConversationLinkConfirmationUiState( + uri = uri, + destination = uri, + showsFullUri = false, + ), + confirmationFor(uri = uri), + ) + } + } + + @Test + fun onExternalUriClicked_openOnTap_opensWithoutConfirmation() { + runTest(context = mainDispatcherRule.testDispatcher) { + givenLinkOpeningMode(mode = LinkOpeningMode.OpenOnTap) + val delegate = createBoundDelegate() + + delegate.effects.test { + delegate.onExternalUriClicked(uri = LINK_URI) + + assertEquals(ConversationScreenEffect.OpenExternalUri(uri = LINK_URI), awaitItem()) + assertNull(delegate.state.value) + } + } + } + + @Test + fun onExternalUriClicked_doNotOpen_ignoresClick() { + runTest(context = mainDispatcherRule.testDispatcher) { + givenLinkOpeningMode(mode = LinkOpeningMode.DoNotOpen) + val delegate = createBoundDelegate() + + delegate.effects.test { + delegate.onExternalUriClicked(uri = LINK_URI) + runCurrent() + + assertNull(delegate.state.value) + expectNoEvents() + } + } + } + + @Test + fun onExternalUriClicked_readsCurrentModeOnEveryClick() { + runTest(context = mainDispatcherRule.testDispatcher) { + coEvery { appSettingsRepository.getLinkOpeningMode() } returnsMany listOf( + LinkOpeningMode.OpenOnTap, + LinkOpeningMode.AskBeforeOpening, + ) + val delegate = createBoundDelegate() + + delegate.effects.test { + delegate.onExternalUriClicked(uri = LINK_URI) + assertEquals(ConversationScreenEffect.OpenExternalUri(uri = LINK_URI), awaitItem()) + + delegate.onExternalUriClicked(uri = LINK_URI) + runCurrent() + + assertEquals(LINK_CONFIRMATION, delegate.state.value) + expectNoEvents() + } + } + } + + @Test + fun confirmOpenLink_opensConfirmedUriOnceAndClearsConfirmation() { + runTest(context = mainDispatcherRule.testDispatcher) { + givenLinkOpeningMode(mode = LinkOpeningMode.AskBeforeOpening) + val delegate = createBoundDelegate() + delegate.onExternalUriClicked(uri = LINK_URI) + runCurrent() + + delegate.effects.test { + delegate.confirmOpenLink(confirmation = LINK_CONFIRMATION) + delegate.confirmOpenLink(confirmation = LINK_CONFIRMATION) + + assertEquals(ConversationScreenEffect.OpenExternalUri(uri = LINK_URI), awaitItem()) + assertNull(delegate.state.value) + runCurrent() + expectNoEvents() + } + } + } + + @Test + fun confirmOpenLink_ofAReplacedConfirmation_opensNothing() { + runTest(context = mainDispatcherRule.testDispatcher) { + givenLinkOpeningMode(mode = LinkOpeningMode.AskBeforeOpening) + val delegate = createBoundDelegate() + delegate.onExternalUriClicked(uri = LINK_URI) + runCurrent() + + delegate.effects.test { + delegate.confirmOpenLink(confirmation = OTHER_LINK_CONFIRMATION) + runCurrent() + + assertEquals(LINK_CONFIRMATION, delegate.state.value) + expectNoEvents() + } + } + } + + @Test + fun onExternalUriClicked_newerClickCancelsAPendingOne() { + runTest(context = mainDispatcherRule.testDispatcher) { + val pendingRead = CompletableDeferred() + coEvery { + appSettingsRepository.getLinkOpeningMode() + } coAnswers { pendingRead.await() } andThen LinkOpeningMode.AskBeforeOpening + val delegate = createBoundDelegate() + delegate.onExternalUriClicked(uri = OTHER_LINK_URI) + runCurrent() + + delegate.onExternalUriClicked(uri = LINK_URI) + runCurrent() + assertEquals(LINK_CONFIRMATION, delegate.state.value) + delegate.dismissOpenLinkConfirmation() + pendingRead.complete(value = LinkOpeningMode.AskBeforeOpening) + runCurrent() + + assertNull(delegate.state.value) + } + } + + @Test + fun dismissOpenLinkConfirmation_cancelsAPendingClick() { + runTest(context = mainDispatcherRule.testDispatcher) { + val pendingRead = givenPendingLinkOpeningModeRead() + val delegate = createBoundDelegate() + delegate.onExternalUriClicked(uri = LINK_URI) + runCurrent() + + delegate.dismissOpenLinkConfirmation() + pendingRead.complete(value = LinkOpeningMode.AskBeforeOpening) + runCurrent() + + assertNull(delegate.state.value) + } + } + + @Test + fun dismissOpenLinkConfirmation_clearsConfirmationWithoutOpening() { + runTest(context = mainDispatcherRule.testDispatcher) { + givenLinkOpeningMode(mode = LinkOpeningMode.AskBeforeOpening) + val delegate = createBoundDelegate() + delegate.onExternalUriClicked(uri = LINK_URI) + runCurrent() + + delegate.effects.test { + delegate.dismissOpenLinkConfirmation() + delegate.confirmOpenLink(confirmation = LINK_CONFIRMATION) + runCurrent() + + assertNull(delegate.state.value) + expectNoEvents() + } + } + } + + @Test + fun conversationChange_clearsPendingConfirmation() { + runTest(context = mainDispatcherRule.testDispatcher) { + givenLinkOpeningMode(mode = LinkOpeningMode.AskBeforeOpening) + val conversationIdFlow = MutableStateFlow(value = CONVERSATION_ID) + val delegate = createBoundDelegate(conversationIdFlow = conversationIdFlow) + delegate.onExternalUriClicked(uri = LINK_URI) + runCurrent() + assertEquals(LINK_CONFIRMATION, delegate.state.value) + + conversationIdFlow.value = OTHER_CONVERSATION_ID + runCurrent() + + assertNull(delegate.state.value) + } + } + + @Test + fun conversationChange_cancelsAPendingClick() { + runTest(context = mainDispatcherRule.testDispatcher) { + val pendingRead = givenPendingLinkOpeningModeRead() + val conversationIdFlow = MutableStateFlow(value = CONVERSATION_ID) + val delegate = createBoundDelegate(conversationIdFlow = conversationIdFlow) + delegate.onExternalUriClicked(uri = LINK_URI) + runCurrent() + + conversationIdFlow.value = OTHER_CONVERSATION_ID + runCurrent() + pendingRead.complete(value = LinkOpeningMode.AskBeforeOpening) + runCurrent() + + assertNull(delegate.state.value) + } + } + + @Test + fun onExternalUriClicked_beforeBind_doesNotReadModeOrOpen() { + runTest(context = mainDispatcherRule.testDispatcher) { + val delegate = createDelegate() + + delegate.effects.test { + delegate.onExternalUriClicked(uri = LINK_URI) + runCurrent() + + assertNull(delegate.state.value) + expectNoEvents() + } + coVerify(exactly = 0) { appSettingsRepository.getLinkOpeningMode() } + } + } + + private fun TestScope.confirmationFor(uri: String): ConversationLinkConfirmationUiState? { + givenLinkOpeningMode(mode = LinkOpeningMode.AskBeforeOpening) + val delegate = createBoundDelegate() + delegate.onExternalUriClicked(uri = uri) + runCurrent() + return delegate.state.value + } + + private fun givenLinkOpeningMode(mode: LinkOpeningMode) { + coEvery { appSettingsRepository.getLinkOpeningMode() } returns mode + } + + private fun givenPendingLinkOpeningModeRead(): CompletableDeferred { + val pendingRead = CompletableDeferred() + coEvery { appSettingsRepository.getLinkOpeningMode() } coAnswers { pendingRead.await() } + return pendingRead + } + + private fun createDelegate(): ConversationLinkDelegateImpl { + return ConversationLinkDelegateImpl( + appSettingsRepository = appSettingsRepository, + defaultDispatcher = mainDispatcherRule.testDispatcher, + ) + } + + private fun TestScope.createBoundDelegate( + conversationIdFlow: MutableStateFlow = + MutableStateFlow(value = CONVERSATION_ID), + ): ConversationLinkDelegateImpl { + return createDelegate().also { delegate -> + delegate.bind(scope = backgroundScope, conversationIdFlow = conversationIdFlow) + runCurrent() + } + } + + private companion object { + private const val ADDRESS_URI = + "geo:0,0?q=1600%20Amphitheatre%20Parkway%2C%20Mountain%20View%2C%20CA" + private const val EMAIL_URI = "mailto:preview%40example.com" + private const val PHONE_URI = "tel:%2B31%206%202222%203333" + private const val LINK_URI = "https://example.com/account/verify" + private const val OTHER_LINK_URI = "https://example.org/" + private val LINK_CONFIRMATION = ConversationLinkConfirmationUiState( + uri = LINK_URI, + destination = "example.com", + showsFullUri = true, + ) + private val OTHER_LINK_CONFIRMATION = ConversationLinkConfirmationUiState( + uri = OTHER_LINK_URI, + destination = "example.org", + showsFullUri = true, + ) + private val OTHER_CONVERSATION_ID = ConversationId("other-conversation") + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/delegate/conversationmessagesdelegate/BaseConversationMessagesDelegateTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/delegate/conversationmessagesdelegate/BaseConversationMessagesDelegateTest.kt index ddebd9450..63dcc5e97 100644 --- a/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/delegate/conversationmessagesdelegate/BaseConversationMessagesDelegateTest.kt +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/delegate/conversationmessagesdelegate/BaseConversationMessagesDelegateTest.kt @@ -3,6 +3,7 @@ package com.android.messaging.ui.conversation.messages.delegate.conversationmess import android.net.Uri import android.os.Parcel import androidx.lifecycle.SavedStateHandle +import com.android.messaging.data.appsettings.model.LinkOpeningMode import com.android.messaging.data.appsettings.repository.AppSettingsRepository import com.android.messaging.data.conversation.model.ConversationId import com.android.messaging.data.conversation.model.MessageId @@ -43,6 +44,7 @@ internal abstract class BaseConversationMessagesDelegateTest { protected val conversationsRepository = mockk() protected val appSettingsRepository = mockk { coEvery { isYouTubeLinkPreviewsEnabled() } returns false + coEvery { getLinkOpeningMode() } returns LinkOpeningMode.AskBeforeOpening } protected val resolveAudioDurationMillis = mockk(relaxed = true) diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/delegate/conversationmessagesdelegate/ConversationMessagesDelegateLinkOpeningTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/delegate/conversationmessagesdelegate/ConversationMessagesDelegateLinkOpeningTest.kt new file mode 100644 index 000000000..ef495da1b --- /dev/null +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/delegate/conversationmessagesdelegate/ConversationMessagesDelegateLinkOpeningTest.kt @@ -0,0 +1,75 @@ +package com.android.messaging.ui.conversation.messages.delegate.conversationmessagesdelegate + +import com.android.messaging.data.appsettings.model.LinkOpeningMode +import com.android.messaging.datamodel.data.ConversationMessageData +import com.android.messaging.testutil.TEST_CONVERSATION_ID as CONVERSATION_ID +import com.android.messaging.ui.conversation.messages.delegate.ConversationMessagesDelegateImpl +import com.android.messaging.ui.conversation.messages.model.message.ConversationMessagesUiState.Present +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +internal class ConversationMessagesDelegateLinkOpeningTest : + BaseConversationMessagesDelegateTest() { + + @Test + fun refresh_followsTheLinkOpeningMode() { + runTest(context = mainDispatcherRule.testDispatcher) { + val messageData = mockk() + val message = messageUiModel(messageId = "message-1") + coEvery { + appSettingsRepository.getLinkOpeningMode() + } returnsMany listOf( + LinkOpeningMode.AskBeforeOpening, + LinkOpeningMode.DoNotOpen, + LinkOpeningMode.OpenOnTap, + ) + every { messageUiModelMapper.map(data = messageData) } returns message + givenConversationMessages(messages = flowOf(listOf(messageData))) + + val delegate = createBoundDelegate( + conversationIdFlow = MutableStateFlow(CONVERSATION_ID), + ) + runCurrent() + val modeAfterEachRefresh = buildList { + add(delegate.presentState().linkOpeningMode) + repeat(times = 2) { + delegate.refresh() + runCurrent() + add(delegate.presentState().linkOpeningMode) + } + } + + assertEquals( + listOf( + LinkOpeningMode.AskBeforeOpening, + LinkOpeningMode.DoNotOpen, + LinkOpeningMode.OpenOnTap, + ), + modeAfterEachRefresh, + ) + assertEquals( + persistentListOf(message), + delegate.presentState().messages, + ) + verify(exactly = 1) { messageUiModelMapper.map(data = messageData) } + } + } + + private fun ConversationMessagesDelegateImpl.presentState(): Present { + return state.value as Present + } +} diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/ui/attachment/rendering/ConversationVisualAttachmentsInteractionTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/ui/attachment/rendering/ConversationVisualAttachmentsInteractionTest.kt index bde4ee66d..bb312a792 100644 --- a/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/ui/attachment/rendering/ConversationVisualAttachmentsInteractionTest.kt +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/ui/attachment/rendering/ConversationVisualAttachmentsInteractionTest.kt @@ -1,5 +1,8 @@ package com.android.messaging.ui.conversation.messages.ui.attachment.rendering +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.hasClickAction +import com.android.messaging.data.appsettings.model.LinkOpeningMode import io.mockk.verify import org.junit.Test import org.junit.runner.RunWith @@ -91,6 +94,39 @@ internal class ConversationVisualAttachmentsInteractionTest : } } + @Test + fun youTubePreviewWithLinksDisabled_isNotClickable() { + setMessageAttachmentsContent( + attachmentSections = gallerySections(youTubeAttachment()), + linkOpeningMode = LinkOpeningMode.DoNotOpen, + ) + + composeTestRule.onAllNodes(hasClickAction()).assertCountEquals(0) + clickMessageAttachmentsAt() + + composeTestRule.runOnIdle { + verify(exactly = 0) { + onExternalUriClick.invoke(any()) + } + } + } + + @Test + fun imageAttachmentWithLinksDisabled_stillOpensContent() { + setMessageAttachmentsContent( + attachmentSections = gallerySections(imageAttachment()), + linkOpeningMode = LinkOpeningMode.DoNotOpen, + ) + + clickMessageAttachmentsAt() + + composeTestRule.runOnIdle { + verify(exactly = 1) { + onAttachmentClick.invoke(IMAGE_CONTENT_TYPE, IMAGE_CONTENT_URI, "") + } + } + } + @Test fun standaloneUnsupportedAttachmentWithContentUri_opensContent() { setStandaloneVisualAttachmentContent( diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/ui/message/rendering/ConversationMessageTextLinkGestureTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/ui/message/rendering/ConversationMessageTextLinkGestureTest.kt index 22c2653b7..229915f75 100644 --- a/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/ui/message/rendering/ConversationMessageTextLinkGestureTest.kt +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/ui/message/rendering/ConversationMessageTextLinkGestureTest.kt @@ -1,13 +1,20 @@ package com.android.messaging.ui.conversation.messages.ui.message.rendering +import android.app.PendingIntent +import android.app.RemoteAction import android.content.ClipboardManager import android.content.Context +import android.content.Intent +import android.graphics.Bitmap +import android.graphics.drawable.Icon import android.view.ViewConfiguration +import android.view.textclassifier.TextClassification import android.view.textclassifier.TextClassificationManager import android.view.textclassifier.TextClassifier import android.view.textclassifier.TextLinks import androidx.compose.foundation.text.contextmenu.data.TextContextMenuItem import androidx.compose.foundation.text.contextmenu.data.TextContextMenuKeys +import androidx.compose.foundation.text.contextmenu.data.TextContextMenuSeparator import androidx.compose.foundation.text.contextmenu.data.TextContextMenuSession import androidx.compose.foundation.text.contextmenu.provider.TextContextMenuDataProvider import androidx.compose.foundation.text.contextmenu.provider.TextContextMenuProvider @@ -19,9 +26,11 @@ import androidx.compose.ui.test.click import androidx.compose.ui.test.longClick import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performTouchInput +import androidx.core.net.toUri import androidx.test.core.app.ApplicationProvider import com.android.common.test.helpers.targetContext import com.android.messaging.R +import com.android.messaging.data.appsettings.model.LinkOpeningMode import com.android.messaging.testutil.TEST_WAIT_TIMEOUT_MILLIS import com.android.messaging.ui.conversation.messages.model.message.ConversationMessageUiModel import io.mockk.verify @@ -29,6 +38,7 @@ import kotlinx.coroutines.awaitCancellation import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test @@ -40,13 +50,24 @@ internal class ConversationMessageTextLinkGestureTest : BaseConversationMessageR private val textContextMenuToolbar = RecordingTextContextMenuToolbar() + @Volatile + private var classifiedSelectionCount = 0 + @Before fun classifyLinksByPattern() { - ApplicationProvider - .getApplicationContext() + val context = ApplicationProvider.getApplicationContext() + val openLinkAction = openLinkAction(context = context) + context .getSystemService(TextClassificationManager::class.java) .setTextClassifier( object : TextClassifier { + override fun classifyText( + request: TextClassification.Request, + ): TextClassification { + classifiedSelectionCount++ + return TextClassification.Builder().addAction(openLinkAction).build() + } + override fun generateLinks(request: TextLinks.Request): TextLinks { val text = request.text.toString() val builder = TextLinks.Builder(text) @@ -132,6 +153,73 @@ internal class ConversationMessageTextLinkGestureTest : BaseConversationMessageR linkText.performTouchInput { up() } } + @Test + fun selectedLink_opensThroughTheAppInsteadOfTheSystemAction() { + setLinkMessageContent() + + selectTextAndAwaitToolbar(text = LINK_TEXT) + assertEquals(0, textContextMenuToolbar.systemActionCount()) + textContextMenuToolbar.click(label = openLinkLabel()) + + composeTestRule.runOnIdle { + verify(exactly = 1) { onExternalUriClick(LINK_TEXT) } + } + } + + @Test + fun selectedTextWithoutALink_keepsTheSystemActionWhenLinksOpenOnTap() { + setConversationMessageContent( + message = message(text = PLAIN_TEXT), + textContextMenuToolbar = textContextMenuToolbar, + linkOpeningMode = LinkOpeningMode.OpenOnTap, + ) + + selectTextAndAwaitToolbar( + text = PLAIN_TEXT, + awaitedItemLabel = selectMessageLabel(), + ) + + assertEquals(1, textContextMenuToolbar.systemActionCount()) + } + + @Test + fun selectedSubjectLink_offersNoWayToOpenItWhenAskingBeforeOpening() { + selectSubjectLink(linkOpeningMode = LinkOpeningMode.AskBeforeOpening) + + composeTestRule.runOnIdle { + assertEquals(0, textContextMenuToolbar.systemActionCount()) + assertNull(textContextMenuToolbar.item(label = openLinkLabel())) + } + } + + @Test + fun selectedSubjectLink_keepsTheSystemActionWhenLinksOpenOnTap() { + selectSubjectLink(linkOpeningMode = LinkOpeningMode.OpenOnTap) + + assertEquals(1, textContextMenuToolbar.systemActionCount()) + } + + @Test + fun selectedDisabledLink_offersNoSystemActionButStillCopiesTheWholeLink() { + setConversationMessageContent( + message = message(text = LINK_TEXT), + textContextMenuToolbar = textContextMenuToolbar, + linkOpeningMode = LinkOpeningMode.DoNotOpen, + ) + composeTestRule.waitUntil(timeoutMillis = TEST_WAIT_TIMEOUT_MILLIS) { + copyLinkActionLabels(text = LINK_TEXT).isNotEmpty() + } + + selectTextAndAwaitToolbar(text = LINK_TEXT) + assertEquals(0, textContextMenuToolbar.systemActionCount()) + assertNull(textContextMenuToolbar.item(label = openLinkLabel())) + textContextMenuToolbar.click(label = copyLinkLabel()) + + composeTestRule.runOnIdle { + verify(exactly = 1) { onCopyText(LINK_TEXT) } + } + } + @Test fun linkLongPressUnderASubject_copiesTheWholeLinkWithoutALeadingNewline() { setLinkMessageContentUnderASubject() @@ -255,6 +343,33 @@ internal class ConversationMessageTextLinkGestureTest : BaseConversationMessageR awaitLinkAnnotated(text = LINK_TEXT) } + private fun selectSubjectLink(linkOpeningMode: LinkOpeningMode) { + setConversationMessageContent( + message = message( + text = null, + mmsSubject = LINK_TEXT, + protocol = ConversationMessageUiModel.Protocol.MMS, + ), + textContextMenuToolbar = textContextMenuToolbar, + linkOpeningMode = linkOpeningMode, + ) + selectTextAndAwaitToolbar(text = LINK_TEXT, awaitedItemLabel = selectMessageLabel()) + } + + private fun openLinkAction(context: Context): RemoteAction { + return RemoteAction( + Icon.createWithBitmap(Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888)), + OPEN_LINK_ACTION_TITLE, + OPEN_LINK_ACTION_TITLE, + PendingIntent.getActivity( + context, + 0, + Intent(Intent.ACTION_VIEW, LINK_TEXT.toUri()), + PendingIntent.FLAG_IMMUTABLE, + ), + ) + } + private fun copiedText(): String { val clipboard = targetContext.getSystemService(ClipboardManager::class.java) return composeTestRule.runOnIdle { @@ -275,6 +390,23 @@ internal class ConversationMessageTextLinkGestureTest : BaseConversationMessageR return linkText } + // A long press outside a link shows the platform magnifier, which Robolectric cannot dismiss. + private fun selectTextAndAwaitToolbar( + text: String, + awaitedItemLabel: String = copyLinkLabel(), + ) { + requireNotNull( + findCustomAction( + text = text, + label = targetContext.getString(R.string.conversation_message_select_text), + ), + ).action() + // The toolbar shows only after text classification, which runs off the main clock. + composeTestRule.waitUntil(timeoutMillis = TEST_WAIT_TIMEOUT_MILLIS) { + classifiedSelectionCount > 0 && textContextMenuToolbar.item(awaitedItemLabel) != null + } + } + private fun clickLinkText(delayMillis: Long = 0) { composeTestRule .onNodeWithText(text = LINK_TEXT, useUnmergedTree = true) @@ -288,6 +420,14 @@ internal class ConversationMessageTextLinkGestureTest : BaseConversationMessageR return targetContext.getString(R.string.conversation_copy_link) } + private fun openLinkLabel(): String { + return targetContext.getString(R.string.conversation_open_link) + } + + private fun selectMessageLabel(): String { + return targetContext.getString(R.string.conversation_message_select_message) + } + private fun copyLinkTargetAction(text: String, target: String): CustomAccessibilityAction { val label = targetContext.getString(R.string.conversation_copy_link_target, target) return requireNotNull(findCustomAction(text = text, label = label)) @@ -341,6 +481,15 @@ internal class ConversationMessageTextLinkGestureTest : BaseConversationMessageR click(item = items().single { it.key == TextContextMenuKeys.CopyKey }) } + // Compose adds the classifier's actions as its own internal component type. + fun systemActionCount(): Int { + return shownMenu + ?.data() + ?.components + ?.count { it !is TextContextMenuItem && it !== TextContextMenuSeparator } + ?: 0 + } + // The platform toolbar keeps redrawing from its data while its dismiss animation runs. fun closedMenuItemLabels(): List { return items(menu = lastShownMenu).map { it.label } @@ -373,6 +522,8 @@ internal class ConversationMessageTextLinkGestureTest : BaseConversationMessageR private const val EMAIL_ADDRESS_TEXT = "name@example.com" private const val CONTACT_DETAILS_TEXT = "Call $PHONE_NUMBER_TEXT or $EMAIL_ADDRESS_TEXT" private const val SUBJECT_TEXT = "Message subject" + private const val PLAIN_TEXT = "See you tonight" + private const val OPEN_LINK_ACTION_TITLE = "Open" private val LINK_PATTERNS = mapOf( TextClassifier.TYPE_URL to Regex(pattern = "https://\\S+"), TextClassifier.TYPE_PHONE to Regex(pattern = "\\+[\\d-]+"), diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/ui/text/links/ConversationTextSelectionCopyTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/ui/text/links/ConversationTextSelectionCopyTest.kt index b1f3ff303..45d4f4c06 100644 --- a/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/ui/text/links/ConversationTextSelectionCopyTest.kt +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/messages/ui/text/links/ConversationTextSelectionCopyTest.kt @@ -1,6 +1,7 @@ package com.android.messaging.ui.conversation.messages.ui.text.links import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.LinkAnnotation import androidx.compose.ui.text.TextLinkStyles import com.android.messaging.ui.conversation.messages.model.text.ConversationTextLink import com.android.messaging.ui.conversation.messages.model.text.ConversationTextLinkKind @@ -9,6 +10,7 @@ import com.android.messaging.ui.conversation.messages.ui.text.clipboardText import com.android.messaging.ui.conversation.messages.ui.text.selectedConversationTextLink import org.junit.Assert.assertEquals import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue import org.junit.Test internal class ConversationTextSelectionCopyTest { @@ -25,6 +27,35 @@ internal class ConversationTextSelectionCopyTest { assertEquals("http://example.com/path", selected?.clipboardText()) } + @Test + fun disabledLinksAreNotClickableButStillCopyTheirFullTarget() { + val text = "Visit example.com/path now" + val link = link(text = text, value = "example.com/path", url = "http://example.com/path") + val annotated = annotated(text = text, links = listOf(link), linksEnabled = false) + + val selected = selectedConversationTextLink( + selectedTexts = listOf(annotated.subSequence(link.start, link.start + 3)), + links = listOf(link), + ) + + assertTrue(annotated.getLinkAnnotations(start = 0, end = text.length).isEmpty()) + assertEquals(link, selected) + assertEquals("http://example.com/path", selected?.clipboardText()) + } + + @Test + fun enabledLinksAreClickable() { + val text = "Visit example.com/path now" + val link = link(text = text, value = "example.com/path", url = "http://example.com/path") + val annotated = annotated(text = text, links = listOf(link)) + + val linkAnnotation = annotated.getLinkAnnotations(start = 0, end = text.length).single() + + assertEquals(link.start, linkAnnotation.start) + assertEquals(link.end, linkAnnotation.end) + assertEquals("http://example.com/path", (linkAnnotation.item as LinkAnnotation.Url).url) + } + @Test fun twoOccurrencesOfTheSameDestinationAreAmbiguous() { val text = "example.com and example.com" @@ -112,10 +143,15 @@ internal class ConversationTextSelectionCopyTest { ) } - private fun annotated(text: String, links: List): AnnotatedString { + private fun annotated( + text: String, + links: List, + linksEnabled: Boolean = true, + ): AnnotatedString { return buildConversationLinkedAnnotatedString( text = text, links = links, + linksEnabled = linksEnabled, linkStyle = TextLinkStyles(), onExternalUriClick = {}, ) diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/screen/viewmodel/BaseConversationViewModelTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/screen/viewmodel/BaseConversationViewModelTest.kt index 974aa219d..048684c1b 100644 --- a/app/src/test/kotlin/com/android/messaging/ui/conversation/screen/viewmodel/BaseConversationViewModelTest.kt +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/screen/viewmodel/BaseConversationViewModelTest.kt @@ -31,6 +31,8 @@ import com.android.messaging.ui.conversation.composer.model.ConversationComposer import com.android.messaging.ui.conversation.composer.model.ConversationDraftState import com.android.messaging.ui.conversation.composer.model.ConversationSubscriptionSelectionState import com.android.messaging.ui.conversation.focus.delegate.ConversationFocusDelegate +import com.android.messaging.ui.conversation.links.delegate.ConversationLinkDelegate +import com.android.messaging.ui.conversation.links.model.ConversationLinkConfirmationUiState 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 @@ -73,6 +75,7 @@ internal abstract class BaseConversationViewModelTest { mediaPickerDelegate: ConversationMediaPickerDelegate = createMediaPickerDelegateMock().mock, metadataDelegate: ConversationMetadataDelegate = createMetadataDelegateMock().mock, focusDelegate: ConversationFocusDelegate = createFocusDelegateMock().mock, + linkDelegate: ConversationLinkDelegate = createLinkDelegateMock().mock, canAddMoreConversationParticipants: CanAddMoreConversationParticipants = mockk { every { invoke(participantCount = any()) } returns false }, @@ -98,6 +101,7 @@ internal abstract class BaseConversationViewModelTest { conversationMediaPickerDelegate = mediaPickerDelegate, conversationMetadataDelegate = metadataDelegate, conversationFocusDelegate = focusDelegate, + conversationLinkDelegate = linkDelegate, conversationSubscriptionSelectionDelegate = subscriptionSelectionDelegate, conversationComposerUiStateMapper = composerUiStateMapper, simSelectionRepository = simSelectionRepository, @@ -359,6 +363,29 @@ internal abstract class BaseConversationViewModelTest { ) } + protected fun createLinkDelegateMock(): LinkDelegateMock { + val bindCalls = mutableListOf() + val stateFlow = MutableStateFlow(value = null) + val effectsFlow = MutableSharedFlow() + val mock = mockk(relaxed = true) + every { mock.state } returns stateFlow + every { mock.effects } returns effectsFlow + every { + mock.bind(any(), any()) + } answers { + bindCalls += BindCall( + scope = firstArg(), + conversationIdFlow = secondArg(), + ) + } + return LinkDelegateMock( + mock = mock, + stateFlow = stateFlow, + effectsFlow = effectsFlow, + bindCalls = bindCalls, + ) + } + protected fun createComposerUiStateMapperMock( mappedUiState: ConversationComposerUiState, ): ConversationComposerUiStateMapper { @@ -475,6 +502,13 @@ internal abstract class BaseConversationViewModelTest { val bindCalls: List, ) + protected data class LinkDelegateMock( + val mock: ConversationLinkDelegate, + val stateFlow: MutableStateFlow, + val effectsFlow: MutableSharedFlow, + val bindCalls: List, + ) + protected companion object { const val EMERGENCY_PHONE_NUMBER = "911" const val MESSAGE_ID = "message-1" diff --git a/app/src/test/kotlin/com/android/messaging/ui/conversation/screen/viewmodel/ConversationViewModelSimSelectionTest.kt b/app/src/test/kotlin/com/android/messaging/ui/conversation/screen/viewmodel/ConversationViewModelSimSelectionTest.kt index 59ef32287..74be3ce9d 100644 --- a/app/src/test/kotlin/com/android/messaging/ui/conversation/screen/viewmodel/ConversationViewModelSimSelectionTest.kt +++ b/app/src/test/kotlin/com/android/messaging/ui/conversation/screen/viewmodel/ConversationViewModelSimSelectionTest.kt @@ -24,6 +24,7 @@ import com.android.messaging.ui.conversation.composer.mapper.ConversationCompose import com.android.messaging.ui.conversation.composer.model.ConversationComposerUiState import com.android.messaging.ui.conversation.composer.model.ConversationDraftState import com.android.messaging.ui.conversation.focus.delegate.ConversationFocusDelegate +import com.android.messaging.ui.conversation.links.delegate.ConversationLinkDelegate 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 @@ -67,6 +68,7 @@ internal class ConversationViewModelSimSelectionTest { private val conversationMediaPickerDelegate = mockk() private val conversationMetadataDelegate = mockk() private val conversationFocusDelegate = mockk() + private val conversationLinkDelegate = mockk() private val conversationComposerUiStateMapper = mockk() private val simSelectionRepository = mockk() private val subscriptionsRepository = mockk() @@ -130,6 +132,10 @@ internal class ConversationViewModelSimSelectionTest { every { conversationFocusDelegate.bind(any(), any()) } just runs + every { conversationLinkDelegate.state } returns MutableStateFlow(null) + every { conversationLinkDelegate.effects } returns emptyFlow() + every { conversationLinkDelegate.bind(any(), any()) } just runs + every { conversationComposerUiStateMapper.map( audioRecording = any(), @@ -297,6 +303,7 @@ internal class ConversationViewModelSimSelectionTest { conversationMediaPickerDelegate = conversationMediaPickerDelegate, conversationMetadataDelegate = conversationMetadataDelegate, conversationFocusDelegate = conversationFocusDelegate, + conversationLinkDelegate = conversationLinkDelegate, conversationSubscriptionSelectionDelegate = subscriptionSelectionDelegate, conversationComposerUiStateMapper = conversationComposerUiStateMapper, simSelectionRepository = simSelectionRepository, diff --git a/res/values/constants.xml b/res/values/constants.xml index 01e328ede..af3a55e7c 100644 --- a/res/values/constants.xml +++ b/res/values/constants.xml @@ -23,6 +23,8 @@ false youtube_link_previews false + link_opening + ask diff --git a/res/values/strings.xml b/res/values/strings.xml index 23b5b52b6..1a5ed693a 100644 --- a/res/values/strings.xml +++ b/res/values/strings.xml @@ -59,6 +59,14 @@ Copy phone number Copy email address Copy address + + Open link + + Call + + Send email + + Show on map Copy link %1$s Copy phone number %1$s Copy email address %1$s @@ -822,6 +830,33 @@ Show video thumbnails for YouTube links in conversations. Enabling this fetches thumbnails from YouTube and may reduce privacy + + Links in messages + + + Ask before opening + + + Show where a link goes and ask before opening it + + + Open on tap + + + Open links as soon as you tap them + + + Don\'t open + + + Show links as plain text. Long-press a link to copy it + + + Open link? + + + Open + \"%s\" SIM diff --git a/src/com/android/messaging/data/appsettings/model/AppSettings.kt b/src/com/android/messaging/data/appsettings/model/AppSettings.kt index 98227fc3b..a306c5a66 100644 --- a/src/com/android/messaging/data/appsettings/model/AppSettings.kt +++ b/src/com/android/messaging/data/appsettings/model/AppSettings.kt @@ -6,6 +6,7 @@ internal data class AppSettings( val sendSoundEnabled: Boolean, val inConversationSoundEnabled: Boolean, val youTubeLinkPreviewsEnabled: Boolean, + val linkOpeningMode: LinkOpeningMode, val isDebugEnabled: Boolean, val dumpSmsEnabled: Boolean, val dumpMmsEnabled: Boolean, diff --git a/src/com/android/messaging/data/appsettings/model/LinkOpeningMode.kt b/src/com/android/messaging/data/appsettings/model/LinkOpeningMode.kt new file mode 100644 index 000000000..b16adc0b2 --- /dev/null +++ b/src/com/android/messaging/data/appsettings/model/LinkOpeningMode.kt @@ -0,0 +1,9 @@ +package com.android.messaging.data.appsettings.model + +internal enum class LinkOpeningMode( + val prefValue: String, +) { + AskBeforeOpening(prefValue = "ask"), + OpenOnTap(prefValue = "open"), + DoNotOpen(prefValue = "off"), +} diff --git a/src/com/android/messaging/data/appsettings/repository/AppSettingsRepository.kt b/src/com/android/messaging/data/appsettings/repository/AppSettingsRepository.kt index 1042ededd..b49226c1d 100644 --- a/src/com/android/messaging/data/appsettings/repository/AppSettingsRepository.kt +++ b/src/com/android/messaging/data/appsettings/repository/AppSettingsRepository.kt @@ -4,6 +4,7 @@ import android.content.Context import com.android.messaging.R import com.android.messaging.data.appsettings.model.AppBooleanPref import com.android.messaging.data.appsettings.model.AppSettings +import com.android.messaging.data.appsettings.model.LinkOpeningMode import com.android.messaging.data.debug.DebugFeaturesProvider import com.android.messaging.di.core.IoDispatcher import com.android.messaging.util.BuglePrefs @@ -16,7 +17,9 @@ import kotlinx.coroutines.withContext internal interface AppSettingsRepository { suspend fun getAppSettings(): AppSettings suspend fun isYouTubeLinkPreviewsEnabled(): Boolean + suspend fun getLinkOpeningMode(): LinkOpeningMode suspend fun setBooleanPref(pref: AppBooleanPref, enabled: Boolean) + suspend fun setLinkOpeningMode(mode: LinkOpeningMode) } internal class AppSettingsRepositoryImpl @Inject constructor( @@ -43,6 +46,7 @@ internal class AppSettingsRepositoryImpl @Inject constructor( resources.getBoolean(R.bool.in_conversation_sound_pref_default), ), youTubeLinkPreviewsEnabled = readYouTubeLinkPreviewsEnabled(), + linkOpeningMode = readLinkOpeningMode(), isDebugEnabled = debugFeaturesProvider.isEnabled(), dumpSmsEnabled = appPrefs.getBoolean( context.getString(R.string.dump_sms_pref_key), @@ -62,6 +66,12 @@ internal class AppSettingsRepositoryImpl @Inject constructor( } } + override suspend fun getLinkOpeningMode(): LinkOpeningMode { + return withContext(ioDispatcher) { + readLinkOpeningMode() + } + } + override suspend fun setBooleanPref( pref: AppBooleanPref, enabled: Boolean, @@ -74,10 +84,34 @@ internal class AppSettingsRepositoryImpl @Inject constructor( } } + override suspend fun setLinkOpeningMode(mode: LinkOpeningMode) { + withContext(ioDispatcher) { + BuglePrefs.getApplicationPrefs().putString( + context.getString(R.string.link_opening_pref_key), + mode.prefValue, + ) + } + } + private fun readYouTubeLinkPreviewsEnabled(): Boolean { return BuglePrefs.getApplicationPrefs().getBoolean( context.getString(R.string.youtube_link_previews_pref_key), context.resources.getBoolean(R.bool.youtube_link_previews_pref_default), ) } + + private fun readLinkOpeningMode(): LinkOpeningMode { + val defaultValue = context.getString(R.string.link_opening_pref_default) + val value = BuglePrefs.getApplicationPrefs().getString( + context.getString(R.string.link_opening_pref_key), + defaultValue, + ) + return linkOpeningModeOrNull(prefValue = value) + ?: linkOpeningModeOrNull(prefValue = defaultValue) + ?: LinkOpeningMode.AskBeforeOpening + } + + private fun linkOpeningModeOrNull(prefValue: String?): LinkOpeningMode? { + return LinkOpeningMode.entries.firstOrNull { mode -> mode.prefValue == prefValue } + } } diff --git a/src/com/android/messaging/di/conversation/ConversationViewModelBindsModule.kt b/src/com/android/messaging/di/conversation/ConversationViewModelBindsModule.kt index 782bf299b..3984912c1 100644 --- a/src/com/android/messaging/di/conversation/ConversationViewModelBindsModule.kt +++ b/src/com/android/messaging/di/conversation/ConversationViewModelBindsModule.kt @@ -16,6 +16,8 @@ import com.android.messaging.ui.conversation.composer.delegate.ConversationSubsc import com.android.messaging.ui.conversation.composer.delegate.ConversationSubscriptionSelectionDelegateImpl import com.android.messaging.ui.conversation.focus.delegate.ConversationFocusDelegate import com.android.messaging.ui.conversation.focus.delegate.ConversationFocusDelegateImpl +import com.android.messaging.ui.conversation.links.delegate.ConversationLinkDelegate +import com.android.messaging.ui.conversation.links.delegate.ConversationLinkDelegateImpl 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 @@ -74,6 +76,12 @@ internal abstract class ConversationViewModelBindsModule { impl: ConversationDraftEditorDelegateImpl, ): ConversationDraftEditorDelegate + @Binds + @ViewModelScoped + abstract fun bindConversationLinkDelegate( + impl: ConversationLinkDelegateImpl, + ): ConversationLinkDelegate + @Binds @ViewModelScoped abstract fun bindConversationMediaPickerDelegate( diff --git a/src/com/android/messaging/ui/appsettings/general/AppSettingsViewModel.kt b/src/com/android/messaging/ui/appsettings/general/AppSettingsViewModel.kt index 534a2a235..f0b9f61c5 100644 --- a/src/com/android/messaging/ui/appsettings/general/AppSettingsViewModel.kt +++ b/src/com/android/messaging/ui/appsettings/general/AppSettingsViewModel.kt @@ -49,6 +49,10 @@ internal class AppSettingsViewModel @Inject constructor( appSettingsDelegate.onYouTubeLinkPreviewsChanged(action.enabled) } + is Action.LinkOpeningModeChanged -> { + appSettingsDelegate.onLinkOpeningModeChanged(action.mode) + } + is Action.NotificationsClicked -> _effects.trySend(Effect.OpenNotificationSettings) is Action.DefaultSmsAppClicked -> { diff --git a/src/com/android/messaging/ui/appsettings/general/delegate/AppSettingsDelegate.kt b/src/com/android/messaging/ui/appsettings/general/delegate/AppSettingsDelegate.kt index 06f0b878e..b21514a25 100644 --- a/src/com/android/messaging/ui/appsettings/general/delegate/AppSettingsDelegate.kt +++ b/src/com/android/messaging/ui/appsettings/general/delegate/AppSettingsDelegate.kt @@ -1,6 +1,7 @@ package com.android.messaging.ui.appsettings.general.delegate import com.android.messaging.data.appsettings.model.AppBooleanPref +import com.android.messaging.data.appsettings.model.LinkOpeningMode import com.android.messaging.data.appsettings.repository.AppSettingsRepository import com.android.messaging.ui.appsettings.common.SettingsScreenDelegate import com.android.messaging.ui.appsettings.general.mapper.AppSettingsUiStateMapper @@ -20,6 +21,7 @@ internal interface AppSettingsDelegate : SettingsScreenDelegate Unit) { + boundScope?.launch { + write() refresh() } } diff --git a/src/com/android/messaging/ui/appsettings/general/mapper/AppSettingsUiStateMapper.kt b/src/com/android/messaging/ui/appsettings/general/mapper/AppSettingsUiStateMapper.kt index 2ca88e2a5..01f4ff8b2 100644 --- a/src/com/android/messaging/ui/appsettings/general/mapper/AppSettingsUiStateMapper.kt +++ b/src/com/android/messaging/ui/appsettings/general/mapper/AppSettingsUiStateMapper.kt @@ -25,6 +25,7 @@ internal class AppSettingsUiStateMapperImpl @Inject constructor( sendSoundEnabled = appSettings.sendSoundEnabled, inConversationSoundEnabled = appSettings.inConversationSoundEnabled, youTubeLinkPreviewsEnabled = appSettings.youTubeLinkPreviewsEnabled, + linkOpeningMode = appSettings.linkOpeningMode, isDebugEnabled = appSettings.isDebugEnabled, dumpSmsEnabled = appSettings.dumpSmsEnabled, dumpMmsEnabled = appSettings.dumpMmsEnabled, diff --git a/src/com/android/messaging/ui/appsettings/general/model/AppSettingsAction.kt b/src/com/android/messaging/ui/appsettings/general/model/AppSettingsAction.kt index beac393d7..33db64997 100644 --- a/src/com/android/messaging/ui/appsettings/general/model/AppSettingsAction.kt +++ b/src/com/android/messaging/ui/appsettings/general/model/AppSettingsAction.kt @@ -1,5 +1,7 @@ package com.android.messaging.ui.appsettings.general.model +import com.android.messaging.data.appsettings.model.LinkOpeningMode + internal sealed interface AppSettingsAction { data object NotificationsClicked : AppSettingsAction @@ -25,6 +27,10 @@ internal sealed interface AppSettingsAction { val enabled: Boolean, ) : AppSettingsAction + data class LinkOpeningModeChanged( + val mode: LinkOpeningMode, + ) : AppSettingsAction + data class DefaultSmsAppClicked( val isCurrentlyDefault: Boolean, ) : AppSettingsAction diff --git a/src/com/android/messaging/ui/appsettings/general/model/AppSettingsUiState.kt b/src/com/android/messaging/ui/appsettings/general/model/AppSettingsUiState.kt index 9a7cdae82..26237fb87 100644 --- a/src/com/android/messaging/ui/appsettings/general/model/AppSettingsUiState.kt +++ b/src/com/android/messaging/ui/appsettings/general/model/AppSettingsUiState.kt @@ -1,6 +1,7 @@ package com.android.messaging.ui.appsettings.general.model import androidx.compose.runtime.Immutable +import com.android.messaging.data.appsettings.model.LinkOpeningMode @Immutable internal data class AppSettingsUiState( @@ -9,6 +10,7 @@ internal data class AppSettingsUiState( val sendSoundEnabled: Boolean = true, val inConversationSoundEnabled: Boolean = false, val youTubeLinkPreviewsEnabled: Boolean = false, + val linkOpeningMode: LinkOpeningMode = LinkOpeningMode.AskBeforeOpening, val isDebugEnabled: Boolean = false, val dumpSmsEnabled: Boolean = false, val dumpMmsEnabled: Boolean = false, diff --git a/src/com/android/messaging/ui/appsettings/privacy/ui/PrivacySettingsScreen.kt b/src/com/android/messaging/ui/appsettings/privacy/ui/PrivacySettingsScreen.kt index 399e5d1ee..4a2636247 100644 --- a/src/com/android/messaging/ui/appsettings/privacy/ui/PrivacySettingsScreen.kt +++ b/src/com/android/messaging/ui/appsettings/privacy/ui/PrivacySettingsScreen.kt @@ -1,16 +1,39 @@ package com.android.messaging.ui.appsettings.privacy.ui +import androidx.annotation.StringRes +import androidx.compose.foundation.layout.Column +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.padding +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.selection.selectableGroup +import androidx.compose.material3.AlertDialog import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.RadioButton import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBarDefaults 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.input.nestedscroll.nestedScroll import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role import androidx.compose.ui.tooling.preview.PreviewLightDark +import androidx.compose.ui.unit.dp import com.android.messaging.R +import com.android.messaging.data.appsettings.model.LinkOpeningMode +import com.android.messaging.ui.appsettings.common.SettingsClickableItem import com.android.messaging.ui.appsettings.common.SettingsSwitchItem import com.android.messaging.ui.appsettings.common.SettingsTopAppBar import com.android.messaging.ui.appsettings.general.model.AppSettingsAction as Action @@ -26,6 +49,7 @@ internal fun PrivacySettingsScreen( modifier: Modifier = Modifier, ) { val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() + var showLinkOpeningDialog by rememberSaveable { mutableStateOf(false) } Scaffold( modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection), @@ -41,6 +65,13 @@ internal fun PrivacySettingsScreen( modifier = Modifier.fillMaxSize(), contentPadding = contentPadding, ) { + item(key = "link_opening") { + SettingsClickableItem( + title = stringResource(R.string.link_opening_pref_title), + summary = stringResource(appSettings.linkOpeningMode.titleResId()), + onClick = { showLinkOpeningDialog = true }, + ) + } item(key = "youtube_link_previews") { SettingsSwitchItem( title = stringResource(R.string.youtube_link_previews_pref_title), @@ -53,6 +84,108 @@ internal fun PrivacySettingsScreen( } } } + + if (showLinkOpeningDialog) { + LinkOpeningDialog( + mode = appSettings.linkOpeningMode, + onDismiss = { showLinkOpeningDialog = false }, + onConfirm = { mode -> + showLinkOpeningDialog = false + onAction(Action.LinkOpeningModeChanged(mode)) + }, + ) + } +} + +@Composable +private fun LinkOpeningDialog( + mode: LinkOpeningMode, + onDismiss: () -> Unit, + onConfirm: (LinkOpeningMode) -> Unit, +) { + var selectedMode by rememberSaveable { mutableStateOf(mode) } + + AlertDialog( + onDismissRequest = onDismiss, + title = { + Text(text = stringResource(R.string.link_opening_pref_title)) + }, + text = { + Column(modifier = Modifier.selectableGroup()) { + LinkOpeningMode.entries.forEach { option -> + LinkOpeningOption( + mode = option, + selected = option == selectedMode, + onClick = { selectedMode = option }, + ) + } + } + }, + confirmButton = { + TextButton(onClick = { onConfirm(selectedMode) }) { + Text(text = stringResource(android.R.string.ok)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(text = stringResource(android.R.string.cancel)) + } + }, + ) +} + +@Composable +private fun LinkOpeningOption( + mode: LinkOpeningMode, + selected: Boolean, + onClick: () -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .selectable( + selected = selected, + onClick = onClick, + role = Role.RadioButton, + ) + .padding(vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + RadioButton( + selected = selected, + onClick = null, + ) + Spacer(modifier = Modifier.width(12.dp)) + Column { + Text( + text = stringResource(mode.titleResId()), + style = MaterialTheme.typography.bodyLarge, + ) + Text( + text = stringResource(mode.summaryResId()), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +@StringRes +private fun LinkOpeningMode.titleResId(): Int { + return when (this) { + LinkOpeningMode.AskBeforeOpening -> R.string.link_opening_ask + LinkOpeningMode.OpenOnTap -> R.string.link_opening_open + LinkOpeningMode.DoNotOpen -> R.string.link_opening_off + } +} + +@StringRes +private fun LinkOpeningMode.summaryResId(): Int { + return when (this) { + LinkOpeningMode.AskBeforeOpening -> R.string.link_opening_ask_summary + LinkOpeningMode.OpenOnTap -> R.string.link_opening_open_summary + LinkOpeningMode.DoNotOpen -> R.string.link_opening_off_summary + } } @PreviewLightDark @@ -68,3 +201,15 @@ private fun PrivacySettingsScreenPreview() { ) } } + +@PreviewLightDark +@Composable +private fun LinkOpeningDialogPreview() { + MessagingPreviewTheme { + LinkOpeningDialog( + mode = LinkOpeningMode.AskBeforeOpening, + onDismiss = {}, + onConfirm = {}, + ) + } +} diff --git a/src/com/android/messaging/ui/conversation/ConversationTestTags.kt b/src/com/android/messaging/ui/conversation/ConversationTestTags.kt index b6f82b23c..35878f92b 100644 --- a/src/com/android/messaging/ui/conversation/ConversationTestTags.kt +++ b/src/com/android/messaging/ui/conversation/ConversationTestTags.kt @@ -37,6 +37,10 @@ internal const val CONVERSATION_DELETE_MESSAGES_CONFIRM_BUTTON_TEST_TAG = "conversation_delete_messages_confirm_button" internal const val CONVERSATION_DELETE_MESSAGES_DISMISS_BUTTON_TEST_TAG = "conversation_delete_messages_dismiss_button" +internal const val CONVERSATION_OPEN_LINK_CONFIRM_BUTTON_TEST_TAG = + "conversation_open_link_confirm_button" +internal const val CONVERSATION_OPEN_LINK_DISMISS_BUTTON_TEST_TAG = + "conversation_open_link_dismiss_button" 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/links/delegate/ConversationLinkDelegate.kt b/src/com/android/messaging/ui/conversation/links/delegate/ConversationLinkDelegate.kt new file mode 100644 index 000000000..f67eaf48d --- /dev/null +++ b/src/com/android/messaging/ui/conversation/links/delegate/ConversationLinkDelegate.kt @@ -0,0 +1,135 @@ +package com.android.messaging.ui.conversation.links.delegate + +import android.net.Uri +import androidx.core.net.toUri +import com.android.messaging.data.appsettings.model.LinkOpeningMode +import com.android.messaging.data.appsettings.repository.AppSettingsRepository +import com.android.messaging.data.conversation.model.ConversationId +import com.android.messaging.di.core.DefaultDispatcher +import com.android.messaging.ui.conversation.common.ConversationScreenDelegate +import com.android.messaging.ui.conversation.links.model.ConversationLinkConfirmationUiState +import com.android.messaging.ui.conversation.screen.model.ConversationScreenEffect +import javax.inject.Inject +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.BufferOverflow +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.map +import kotlinx.coroutines.flow.merge +import kotlinx.coroutines.launch + +internal interface ConversationLinkActions { + fun onExternalUriClicked(uri: String) + fun confirmOpenLink(confirmation: ConversationLinkConfirmationUiState) + fun dismissOpenLinkConfirmation() +} + +internal interface ConversationLinkDelegate : + ConversationScreenDelegate, + ConversationLinkActions { + val effects: Flow +} + +internal class ConversationLinkDelegateImpl @Inject constructor( + private val appSettingsRepository: AppSettingsRepository, + @param:DefaultDispatcher + private val defaultDispatcher: CoroutineDispatcher, +) : ConversationLinkDelegate { + + private val _effects = MutableSharedFlow(extraBufferCapacity = 1) + private val _state = MutableStateFlow(value = null) + + // A null uri cancels a click still waiting for the setting. + private val clickedUris = MutableSharedFlow( + extraBufferCapacity = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + + 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) { + merge(conversationIdFlow.map { null }, clickedUris).collectLatest { uri -> + _state.value = null + uri?.let { handleClick(uri = it) } + } + } + } + + override fun onExternalUriClicked(uri: String) { + clickedUris.tryEmit(uri) + } + + override fun confirmOpenLink(confirmation: ConversationLinkConfirmationUiState) { + if (_state.compareAndSet(expect = confirmation, update = null)) { + boundScope?.launch(defaultDispatcher) { + openUri(uri = confirmation.uri) + } + } + } + + override fun dismissOpenLinkConfirmation() { + _state.value = null + clickedUris.tryEmit(null) + } + + private suspend fun handleClick(uri: String) { + when (appSettingsRepository.getLinkOpeningMode()) { + LinkOpeningMode.AskBeforeOpening -> { + _state.value = linkConfirmationUiState(uri = uri) + } + + LinkOpeningMode.OpenOnTap -> openUri(uri = uri) + LinkOpeningMode.DoNotOpen -> Unit + } + } + + private suspend fun openUri(uri: String) { + _effects.emit(ConversationScreenEffect.OpenExternalUri(uri = uri)) + } + + private fun linkConfirmationUiState(uri: String): ConversationLinkConfirmationUiState { + val parsedUri = uri.toUri() + val host = parsedUri.host?.takeIf { it.isNotBlank() } + + return ConversationLinkConfirmationUiState( + uri = uri, + destination = host ?: parsedUri.readableTarget(), + showsFullUri = host != null, + ) + } + + private fun Uri.readableTarget(): String { + val target = schemeSpecificPart.orEmpty() + + val readableTarget = when (scheme?.lowercase()) { + GEO_SCHEME -> target.substringAfter(delimiter = GEO_QUERY_PREFIX) + else -> target + } + + return readableTarget.ifBlank { toString() } + } + + private companion object { + private const val GEO_SCHEME = "geo" + private const val GEO_QUERY_PREFIX = "?q=" + } +} diff --git a/src/com/android/messaging/ui/conversation/links/model/ConversationLinkConfirmationUiState.kt b/src/com/android/messaging/ui/conversation/links/model/ConversationLinkConfirmationUiState.kt new file mode 100644 index 000000000..b15cc206e --- /dev/null +++ b/src/com/android/messaging/ui/conversation/links/model/ConversationLinkConfirmationUiState.kt @@ -0,0 +1,10 @@ +package com.android.messaging.ui.conversation.links.model + +import androidx.compose.runtime.Immutable + +@Immutable +internal data class ConversationLinkConfirmationUiState( + val uri: String, + val destination: String, + val showsFullUri: Boolean, +) diff --git a/src/com/android/messaging/ui/conversation/links/ui/ConversationLinkConfirmationDialog.kt b/src/com/android/messaging/ui/conversation/links/ui/ConversationLinkConfirmationDialog.kt new file mode 100644 index 000000000..78fda766b --- /dev/null +++ b/src/com/android/messaging/ui/conversation/links/ui/ConversationLinkConfirmationDialog.kt @@ -0,0 +1,110 @@ +package com.android.messaging.ui.conversation.links.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.PreviewLightDark +import androidx.compose.ui.unit.dp +import com.android.messaging.R +import com.android.messaging.ui.conversation.CONVERSATION_OPEN_LINK_CONFIRM_BUTTON_TEST_TAG +import com.android.messaging.ui.conversation.CONVERSATION_OPEN_LINK_DISMISS_BUTTON_TEST_TAG +import com.android.messaging.ui.conversation.links.model.ConversationLinkConfirmationUiState +import com.android.messaging.ui.core.MessagingPreviewTheme + +@Composable +internal fun ConversationLinkConfirmationDialog( + confirmation: ConversationLinkConfirmationUiState, + onConfirm: () -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + AlertDialog( + modifier = modifier, + onDismissRequest = onDismiss, + title = { + Text(text = stringResource(R.string.conversation_open_link_title)) + }, + text = { + ConversationLinkConfirmationTarget(confirmation = confirmation) + }, + confirmButton = { + TextButton( + modifier = Modifier.testTag(tag = CONVERSATION_OPEN_LINK_CONFIRM_BUTTON_TEST_TAG), + onClick = onConfirm, + ) { + Text(text = stringResource(R.string.conversation_open_link_confirm)) + } + }, + dismissButton = { + TextButton( + modifier = Modifier.testTag(tag = CONVERSATION_OPEN_LINK_DISMISS_BUTTON_TEST_TAG), + onClick = onDismiss, + ) { + Text(text = stringResource(android.R.string.cancel)) + } + }, + ) +} + +@Composable +private fun ConversationLinkConfirmationTarget( + confirmation: ConversationLinkConfirmationUiState, +) { + Column( + modifier = Modifier.verticalScroll(state = rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(space = 8.dp), + ) { + Text( + text = confirmation.destination, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + if (confirmation.showsFullUri) { + Text( + text = confirmation.uri, + style = MaterialTheme.typography.bodyMedium, + ) + } + } +} + +@PreviewLightDark +@Composable +private fun ConversationLinkConfirmationDialogWebPreview() { + MessagingPreviewTheme { + ConversationLinkConfirmationDialog( + confirmation = ConversationLinkConfirmationUiState( + uri = "https://example.com/account/verify?session=4f2a9c", + destination = "example.com", + showsFullUri = true, + ), + onConfirm = {}, + onDismiss = {}, + ) + } +} + +@PreviewLightDark +@Composable +private fun ConversationLinkConfirmationDialogPhonePreview() { + MessagingPreviewTheme { + ConversationLinkConfirmationDialog( + confirmation = ConversationLinkConfirmationUiState( + uri = "tel:%2B31%206%202222%203333", + destination = "+31 6 2222 3333", + showsFullUri = false, + ), + onConfirm = {}, + onDismiss = {}, + ) + } +} 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 60be20376..5f0907c4a 100644 --- a/src/com/android/messaging/ui/conversation/messages/delegate/ConversationMessagesDelegate.kt +++ b/src/com/android/messaging/ui/conversation/messages/delegate/ConversationMessagesDelegate.kt @@ -2,6 +2,7 @@ package com.android.messaging.ui.conversation.messages.delegate import android.os.Bundle import androidx.lifecycle.SavedStateHandle +import com.android.messaging.data.appsettings.model.LinkOpeningMode import com.android.messaging.data.appsettings.repository.AppSettingsRepository import com.android.messaging.data.conversation.model.ConversationId import com.android.messaging.data.conversation.model.MessageId @@ -451,7 +452,8 @@ internal class ConversationMessagesDelegateImpl @Inject constructor( }, observeYouTubeLinkPreviewsEnabled(), messageTargets, - ) { content, youTubeLinkPreviewsEnabled, targets -> + observeLinkOpeningMode(), + ) { content, youTubeLinkPreviewsEnabled, targets, linkOpeningMode -> ConversationMessagesUiState.Present( messages = content.messages, youTubeLinkPreviewsEnabled = youTubeLinkPreviewsEnabled, @@ -460,6 +462,7 @@ internal class ConversationMessagesDelegateImpl @Inject constructor( windowId = content.windowId, hasNewerMessages = content.hasNewerMessages, latestUnloadedMessage = content.latestUnloadedMessage, + linkOpeningMode = linkOpeningMode, ) } .flowOn(defaultDispatcher) @@ -472,6 +475,13 @@ internal class ConversationMessagesDelegateImpl @Inject constructor( .distinctUntilChanged() } + private fun observeLinkOpeningMode(): Flow { + return refreshTriggers + .onStart { emit(Unit) } + .map { appSettingsRepository.getLinkOpeningMode() } + .distinctUntilChanged() + } + private fun observeMessagesWithVCardMetadata( messages: List, ): Flow> { diff --git a/src/com/android/messaging/ui/conversation/messages/model/message/ConversationMessagesUiState.kt b/src/com/android/messaging/ui/conversation/messages/model/message/ConversationMessagesUiState.kt index dea717111..a7cb82bc0 100644 --- a/src/com/android/messaging/ui/conversation/messages/model/message/ConversationMessagesUiState.kt +++ b/src/com/android/messaging/ui/conversation/messages/model/message/ConversationMessagesUiState.kt @@ -1,6 +1,7 @@ package com.android.messaging.ui.conversation.messages.model.message import androidx.compose.runtime.Immutable +import com.android.messaging.data.appsettings.model.LinkOpeningMode import com.android.messaging.data.conversation.model.MessageId import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf @@ -31,5 +32,6 @@ internal sealed interface ConversationMessagesUiState { val windowId: Int = 0, val hasNewerMessages: Boolean = false, val latestUnloadedMessage: ConversationMessageUiModel? = null, + val linkOpeningMode: LinkOpeningMode = LinkOpeningMode.AskBeforeOpening, ) : ConversationMessagesUiState } 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 556db2a5a..e6268ce09 100644 --- a/src/com/android/messaging/ui/conversation/messages/ui/ConversationMessages.kt +++ b/src/com/android/messaging/ui/conversation/messages/ui/ConversationMessages.kt @@ -14,6 +14,7 @@ import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.Immutable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier @@ -25,6 +26,7 @@ import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.android.messaging.R +import com.android.messaging.data.appsettings.model.LinkOpeningMode import com.android.messaging.data.conversation.model.MessageId import com.android.messaging.data.conversation.model.ParticipantId import com.android.messaging.data.subscription.model.Subscription @@ -37,6 +39,7 @@ import com.android.messaging.ui.conversation.messages.ui.message.ConversationMes 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.messages.ui.text.LocalConversationMessageLinkOpeningMode import com.android.messaging.ui.conversation.preview.previewMessages import com.android.messaging.ui.conversation.preview.previewSubscriptions import com.android.messaging.ui.conversation.screen.model.ConversationMessageAction @@ -79,6 +82,7 @@ internal fun ConversationMessages( highlightedMessageId: MessageId? = null, showIncomingParticipantIdentity: Boolean = true, youTubeLinkPreviewsEnabled: Boolean = false, + linkOpeningMode: LinkOpeningMode = LinkOpeningMode.AskBeforeOpening, subscriptions: ImmutableList = persistentListOf(), currentSendSimDisplayName: String? = null, additionalTopContentPadding: Dp = 0.dp, @@ -107,44 +111,46 @@ internal fun ConversationMessages( currentSendSimDisplayName = currentSendSimDisplayName, ) > 0 - LazyColumn( - state = listState, - reverseLayout = true, - modifier = modifier - .fillMaxSize() - .testTag(CONVERSATION_MESSAGES_LIST_TEST_TAG) - .background(color = MaterialTheme.colorScheme.background), - contentPadding = conversationMessagesContentPadding( - shouldShowSendSimIndicator = shouldShowSendSimIndicator, - additionalTopContentPadding = additionalTopContentPadding, - ), - ) { - conversationSendSimIndicatorItem( - currentSendSimDisplayName = currentSendSimDisplayName, - shouldShowSendSimIndicator = shouldShowSendSimIndicator, - isSelectionMode = isSelectionMode, - onSimSelectorClick = onSimSelectorClick, - ) + CompositionLocalProvider(LocalConversationMessageLinkOpeningMode provides linkOpeningMode) { + LazyColumn( + state = listState, + reverseLayout = true, + modifier = modifier + .fillMaxSize() + .testTag(CONVERSATION_MESSAGES_LIST_TEST_TAG) + .background(color = MaterialTheme.colorScheme.background), + contentPadding = conversationMessagesContentPadding( + shouldShowSendSimIndicator = shouldShowSendSimIndicator, + additionalTopContentPadding = additionalTopContentPadding, + ), + ) { + conversationSendSimIndicatorItem( + currentSendSimDisplayName = currentSendSimDisplayName, + shouldShowSendSimIndicator = shouldShowSendSimIndicator, + isSelectionMode = isSelectionMode, + onSimSelectorClick = onSimSelectorClick, + ) - conversationMessageItems( - displayMessages = displayMessages, - timeZone = timeZone, - selectedMessageIds = selectedMessageIds, - highlightedMessageId = highlightedMessageId, - isSelectionMode = isSelectionMode, - showIncomingParticipantIdentity = showIncomingParticipantIdentity, - youTubeLinkPreviewsEnabled = youTubeLinkPreviewsEnabled, - simDisplayNameByParticipantId = simDisplayNameByParticipantId, - onAttachmentClick = onAttachmentClick, - onExternalUriClick = onExternalUriClick, - onMessageClick = onMessageClick, - onMessageAvatarClick = onMessageAvatarClick, - onMessageDownloadClick = onMessageDownloadClick, - onMessageActionClick = onMessageActionClick, - onMessageLongClick = onMessageLongClick, - onMessageResendClick = onMessageResendClick, - onSimSelectorClick = onSimSelectorClick, - ) + conversationMessageItems( + displayMessages = displayMessages, + timeZone = timeZone, + selectedMessageIds = selectedMessageIds, + highlightedMessageId = highlightedMessageId, + isSelectionMode = isSelectionMode, + showIncomingParticipantIdentity = showIncomingParticipantIdentity, + youTubeLinkPreviewsEnabled = youTubeLinkPreviewsEnabled, + simDisplayNameByParticipantId = simDisplayNameByParticipantId, + onAttachmentClick = onAttachmentClick, + onExternalUriClick = onExternalUriClick, + onMessageClick = onMessageClick, + onMessageAvatarClick = onMessageAvatarClick, + onMessageDownloadClick = onMessageDownloadClick, + onMessageActionClick = onMessageActionClick, + onMessageLongClick = onMessageLongClick, + onMessageResendClick = onMessageResendClick, + onSimSelectorClick = onSimSelectorClick, + ) + } } } diff --git a/src/com/android/messaging/ui/conversation/messages/ui/attachment/ConversationVisualAttachments.kt b/src/com/android/messaging/ui/conversation/messages/ui/attachment/ConversationVisualAttachments.kt index 0aa7a95e1..bb9eed100 100644 --- a/src/com/android/messaging/ui/conversation/messages/ui/attachment/ConversationVisualAttachments.kt +++ b/src/com/android/messaging/ui/conversation/messages/ui/attachment/ConversationVisualAttachments.kt @@ -35,9 +35,12 @@ 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.data.appsettings.model.LinkOpeningMode import com.android.messaging.ui.common.components.attachment.MediaThumbnail +import com.android.messaging.ui.conversation.messages.model.attachment.ConversationAttachmentOpenAction import com.android.messaging.ui.conversation.messages.model.attachment.ConversationMessageAttachment import com.android.messaging.ui.conversation.messages.model.message.ConversationMessagePartUiModel +import com.android.messaging.ui.conversation.messages.ui.text.LocalConversationMessageLinkOpeningMode import com.android.messaging.ui.conversation.preview.previewMessageAttachments import com.android.messaging.ui.core.MessagingPreviewColumn import com.android.messaging.util.ContentType @@ -212,8 +215,11 @@ private fun ConversationVisualAttachmentSurface( overlay: @Composable BoxScope.() -> Unit, ) { val density = LocalDensity.current - val openAction = remember(attachment) { - attachment.toConversationAttachmentOpenActionOrNull() + val linksEnabled = LocalConversationMessageLinkOpeningMode.current != LinkOpeningMode.DoNotOpen + val openAction = remember(attachment, linksEnabled) { + attachment.toConversationAttachmentOpenActionOrNull()?.takeIf { action -> + linksEnabled || action !is ConversationAttachmentOpenAction.OpenExternal + } } val description = stringResource( id = when { 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 2adaf3c43..8a8adc899 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 @@ -59,7 +59,10 @@ internal fun ConversationMessageBubble( .widthIn(max = maxBubbleWidth) .then(other = modifier) - ConversationTextSelectionContainer(enabled = !isSelectionMode) { + ConversationTextSelectionContainer( + enabled = !isSelectionMode, + onExternalUriClick = onExternalUriClick, + ) { when (layout.bubbleLayoutMode) { ConversationMessageBubbleLayoutMode.AttachmentOnlyWithoutSurface -> { ConversationMessageAttachmentOnlyBubble( 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 772fdbbec..f7f0a811c 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 @@ -27,6 +27,7 @@ import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.unit.dp +import com.android.messaging.data.appsettings.model.LinkOpeningMode import com.android.messaging.ui.core.MessagingPreviewColumn import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -52,6 +53,11 @@ internal val LocalConversationMessageLinkColor: ProvidableCompositionLocal = + compositionLocalOf { + LinkOpeningMode.AskBeforeOpening + } + @Composable internal fun ConversationMessageText( modifier: Modifier = Modifier, @@ -68,6 +74,7 @@ internal fun ConversationMessageText( val gesture = remember { ConversationTextLinkGesture() } val hapticFeedback = LocalHapticFeedback.current val linkStyle = rememberConversationTextLinkStyle() + val linksEnabled = LocalConversationMessageLinkOpeningMode.current != LinkOpeningMode.DoNotOpen val linkClick = remember(state, coordinator, gesture) { { uri: String -> when { @@ -84,10 +91,11 @@ internal fun ConversationMessageText( } state.updateLinks(detectedLinks = links) } - val textWithLinks = remember(text, state.links, linkStyle, linkClick) { + val textWithLinks = remember(text, state.links, linksEnabled, linkStyle, linkClick) { buildConversationLinkedAnnotatedString( text = text, links = state.links, + linksEnabled = linksEnabled, linkStyle = linkStyle, onExternalUriClick = linkClick, ) @@ -183,6 +191,25 @@ private fun ConversationMessageTextLinksPreview() { } } +@PreviewLightDark +@Composable +private fun ConversationMessageTextLinksDisabledPreview() { + ConversationMessageTextPreviewColumn { + CompositionLocalProvider( + LocalConversationMessageLinkOpeningMode provides LinkOpeningMode.DoNotOpen, + ) { + ConversationMessageTextPreviewSample( + text = PREVIEW_MIXED_LINK_TEXT, + style = MaterialTheme.typography.bodyLarge, + ) + ConversationMessageTextPreviewSample( + text = PREVIEW_EMAIL_LINK_TEXT, + style = MaterialTheme.typography.bodyLarge, + ) + } + } +} + @PreviewLightDark @Composable private fun ConversationMessageTextTypographyPreview() { diff --git a/src/com/android/messaging/ui/conversation/messages/ui/text/ConversationTextAnnotations.kt b/src/com/android/messaging/ui/conversation/messages/ui/text/ConversationTextAnnotations.kt index e4fcbd856..1d0965ced 100644 --- a/src/com/android/messaging/ui/conversation/messages/ui/text/ConversationTextAnnotations.kt +++ b/src/com/android/messaging/ui/conversation/messages/ui/text/ConversationTextAnnotations.kt @@ -12,21 +12,24 @@ private const val LINK_OCCURRENCE_TAG = "conversation-link-occurrence" internal fun buildConversationLinkedAnnotatedString( text: String, links: List, + linksEnabled: Boolean, linkStyle: TextLinkStyles, onExternalUriClick: (String) -> Unit, ): AnnotatedString { return buildAnnotatedString { append(text) links.forEach { link -> - addLink( - url = LinkAnnotation.Url( - url = link.url, - styles = linkStyle, - linkInteractionListener = { onExternalUriClick(link.url) }, - ), - start = link.start, - end = link.end, - ) + if (linksEnabled) { + addLink( + url = LinkAnnotation.Url( + url = link.url, + styles = linkStyle, + linkInteractionListener = { onExternalUriClick(link.url) }, + ), + start = link.start, + end = link.end, + ) + } addStringAnnotation( tag = LINK_OCCURRENCE_TAG, annotation = conversationLinkOccurrenceId(link = link), diff --git a/src/com/android/messaging/ui/conversation/messages/ui/text/ConversationTextSelectionContainer.kt b/src/com/android/messaging/ui/conversation/messages/ui/text/ConversationTextSelectionContainer.kt index 453039f24..623ec6e05 100644 --- a/src/com/android/messaging/ui/conversation/messages/ui/text/ConversationTextSelectionContainer.kt +++ b/src/com/android/messaging/ui/conversation/messages/ui/text/ConversationTextSelectionContainer.kt @@ -5,6 +5,9 @@ import androidx.annotation.StringRes import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.text.contextmenu.builder.item +import androidx.compose.foundation.text.contextmenu.data.TextContextMenuComponent +import androidx.compose.foundation.text.contextmenu.data.TextContextMenuItem +import androidx.compose.foundation.text.contextmenu.data.TextContextMenuSeparator import androidx.compose.foundation.text.contextmenu.modifier.appendTextContextMenuComponents import androidx.compose.foundation.text.contextmenu.modifier.filterTextContextMenuComponents import androidx.compose.foundation.text.selection.DisableSelection @@ -14,6 +17,7 @@ import androidx.compose.foundation.text.selection.rememberSelectionState import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Modifier @@ -26,16 +30,24 @@ import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.CustomAccessibilityAction import com.android.messaging.R +import com.android.messaging.data.appsettings.model.LinkOpeningMode import com.android.messaging.ui.conversation.messages.model.text.ConversationTextLink import com.android.messaging.ui.conversation.messages.model.text.ConversationTextLinkKind @Composable internal fun ConversationTextSelectionContainer( enabled: Boolean, + onExternalUriClick: (String) -> Unit, content: @Composable () -> Unit, ) { val state = LocalConversationTextSelection.current val selection = state?.selection ?: rememberSelectionState() + val linkOpeningMode = LocalConversationMessageLinkOpeningMode.current + val open = rememberConversationLinkOpenAction( + state = state.takeIf { linkOpeningMode != LinkOpeningMode.DoNotOpen }, + onExternalUriClick = onExternalUriClick, + ) + val currentOpen = rememberUpdatedState(newValue = open) val copy = rememberConversationLinkCopyAction(state = state) val currentCopy = rememberUpdatedState(newValue = copy) val selectMessageLabel = stringResource(R.string.conversation_message_select_message) @@ -50,6 +62,12 @@ internal fun ConversationTextSelectionContainer( modifier = Modifier .appendTextContextMenuComponents { if (state != null && state.hasSelection && enabled) { + currentOpen.value?.let { action -> + item(key = "open-detected-content", label = action.label) { + action.action() + close() + } + } currentCopy.value?.let { action -> item(key = "copy-detected-content", label = action.label) { action.action() @@ -63,9 +81,14 @@ internal fun ConversationTextSelectionContainer( } } // SelectionContainer clears a selection before it hides its toolbar, which would - // otherwise redraw with only "Select all" while it fades out. - .filterTextContextMenuComponents { - selection.selectedTexts.any { text -> text.isNotEmpty() } + // otherwise redraw with only "Select all" while it fades out. The system's smart + // actions open what they detect without asking, even in text the app does not + // linkify such as a subject, so they stay only when links open on tap. + .filterTextContextMenuComponents { component -> + val keepsSystemActions = + linkOpeningMode == LinkOpeningMode.OpenOnTap && state?.selectedLink == null + selection.selectedTexts.any { text -> text.isNotEmpty() } && + (keepsSystemActions || !component.isSmartSelectionAction()) }, ) { when { @@ -123,6 +146,31 @@ private fun rememberConversationLinkCopyAction( } } +@Composable +private fun rememberConversationLinkOpenAction( + state: ConversationTextSelectionState?, + onExternalUriClick: (String) -> Unit, +): CustomAccessibilityAction? { + val currentOnExternalUriClick by rememberUpdatedState(newValue = onExternalUriClick) + val selectedLink = state?.selectedLink + val label = selectedLink?.let { link -> stringResource(id = link.kind.openLabelRes()) } + return remember(state, selectedLink, label) { + when { + state == null || selectedLink == null || label == null -> null + else -> { + CustomAccessibilityAction(label = label) { + val matchesDisplayedAction = state.selectedLink == selectedLink + if (matchesDisplayedAction) { + state.selection.clear() + currentOnExternalUriClick(selectedLink.url) + } + matchesDisplayedAction + } + } + } + } +} + @Composable internal fun conversationTextAccessibilityActions( hasText: Boolean, @@ -171,6 +219,10 @@ private fun rememberConversationLinkCopyActions( } } +private fun TextContextMenuComponent.isSmartSelectionAction(): Boolean { + return this !is TextContextMenuItem && this !== TextContextMenuSeparator +} + @StringRes private fun ConversationTextLinkKind.copyLabelRes(): Int { return when (this) { @@ -181,6 +233,16 @@ private fun ConversationTextLinkKind.copyLabelRes(): Int { } } +@StringRes +private fun ConversationTextLinkKind.openLabelRes(): Int { + return when (this) { + ConversationTextLinkKind.Web -> R.string.conversation_open_link + ConversationTextLinkKind.Phone -> R.string.conversation_call_phone_number + ConversationTextLinkKind.Email -> R.string.conversation_send_email + ConversationTextLinkKind.Address -> R.string.conversation_show_address_on_map + } +} + @StringRes private fun ConversationTextLinkKind.copyTargetLabelRes(): Int { return when (this) { diff --git a/src/com/android/messaging/ui/conversation/screen/ConversationScreenContent.kt b/src/com/android/messaging/ui/conversation/screen/ConversationScreenContent.kt index 948ad8626..0bcdbff54 100644 --- a/src/com/android/messaging/ui/conversation/screen/ConversationScreenContent.kt +++ b/src/com/android/messaging/ui/conversation/screen/ConversationScreenContent.kt @@ -347,6 +347,7 @@ private fun ConversationScreenPresentContent( highlightedMessageId = messagesState.highlightedMessageId, showIncomingParticipantIdentity = showIncomingParticipantIdentity, youTubeLinkPreviewsEnabled = messagesState.youTubeLinkPreviewsEnabled, + linkOpeningMode = messagesState.linkOpeningMode, subscriptions = uiState.composer.simSelector.subscriptions, currentSendSimDisplayName = currentSendSimDisplayName, additionalTopContentPadding = additionalTopContentPadding, diff --git a/src/com/android/messaging/ui/conversation/screen/ConversationScreenDialogs.kt b/src/com/android/messaging/ui/conversation/screen/ConversationScreenDialogs.kt index 33dd29234..1747c7b03 100644 --- a/src/com/android/messaging/ui/conversation/screen/ConversationScreenDialogs.kt +++ b/src/com/android/messaging/ui/conversation/screen/ConversationScreenDialogs.kt @@ -31,6 +31,7 @@ import com.android.messaging.ui.conversation.CONVERSATION_DELETE_MESSAGES_DISMIS 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.links.ui.ConversationLinkConfirmationDialog 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 @@ -72,6 +73,14 @@ internal fun ConversationScreenDialogs( onDismiss = screenModel::onSubjectDialogDismiss, ) } + + uiState.linkConfirmation?.let { confirmation -> + ConversationLinkConfirmationDialog( + confirmation = confirmation, + onConfirm = { screenModel.confirmOpenLink(confirmation = confirmation) }, + onDismiss = screenModel::dismissOpenLinkConfirmation, + ) + } } @Composable diff --git a/src/com/android/messaging/ui/conversation/screen/ConversationViewModel.kt b/src/com/android/messaging/ui/conversation/screen/ConversationViewModel.kt index fc0d00516..687d5d231 100644 --- a/src/com/android/messaging/ui/conversation/screen/ConversationViewModel.kt +++ b/src/com/android/messaging/ui/conversation/screen/ConversationViewModel.kt @@ -29,6 +29,9 @@ import com.android.messaging.ui.conversation.composer.model.ComposerAttachmentUi 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.links.delegate.ConversationLinkActions +import com.android.messaging.ui.conversation.links.delegate.ConversationLinkDelegate +import com.android.messaging.ui.conversation.links.model.ConversationLinkConfirmationUiState import com.android.messaging.ui.conversation.mediapicker.delegate.ConversationMediaPickerAttachmentActions import com.android.messaging.ui.conversation.mediapicker.delegate.ConversationMediaPickerDelegate import com.android.messaging.ui.conversation.messages.delegate.ConversationMessageSelectionActions @@ -59,6 +62,7 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch internal interface ConversationScreenModel : + ConversationLinkActions, ConversationMediaPickerAttachmentActions, ConversationMessageSelectionActions { val effects: Flow @@ -104,8 +108,6 @@ internal interface ConversationScreenModel : fun onSimSelected(selfParticipantId: ParticipantId) - fun onExternalUriClicked(uri: String) - fun onMessageTextChanged(text: String, textRevision: Int) fun tryStartAddingAttachment(): Boolean fun onAudioRecordingStart(isLocked: Boolean) @@ -153,6 +155,7 @@ internal class ConversationViewModel @Inject constructor( private val conversationMediaPickerDelegate: ConversationMediaPickerDelegate, private val conversationMetadataDelegate: ConversationMetadataDelegate, private val conversationFocusDelegate: ConversationFocusDelegate, + private val conversationLinkDelegate: ConversationLinkDelegate, private val conversationSubscriptionSelectionDelegate: ConversationSubscriptionSelectionDelegate, conversationComposerUiStateMapper: ConversationComposerUiStateMapper, @@ -167,6 +170,7 @@ internal class ConversationViewModel @Inject constructor( private val resolveContactAction: ResolveContactAction, ) : ViewModel(), ConversationScreenModel, + ConversationLinkActions by conversationLinkDelegate, ConversationMediaPickerAttachmentActions by conversationMediaPickerDelegate, ConversationMessageSelectionActions by conversationMessageSelectionDelegate { @@ -227,13 +231,9 @@ internal class ConversationViewModel @Inject constructor( conversationDraftDelegate.attachmentLimitWarning, conversationMetadataDelegate.isDeleteConversationConfirmationVisible, conversationDraftDelegate.isSubjectDialogVisible, - ) { attachmentLimitWarning, isDeleteConversationConfirmationVisible, isSubjectDialogVisible -> - ConversationScreenDialogUiState( - attachmentLimitWarning = attachmentLimitWarning, - isDeleteConversationConfirmationVisible = isDeleteConversationConfirmationVisible, - isSubjectDialogVisible = isSubjectDialogVisible, - ) - } + conversationLinkDelegate.state, + ::ConversationScreenDialogUiState, + ) override val scaffoldUiState: StateFlow = combine( conversationMetadataDelegate.state, @@ -251,6 +251,7 @@ internal class ConversationViewModel @Inject constructor( isDeleteConversationConfirmationVisible = dialogUiState .isDeleteConversationConfirmationVisible, isSubjectDialogVisible = dialogUiState.isSubjectDialogVisible, + linkConfirmation = dialogUiState.linkConfirmation, ) }.stateIn( scope = viewModelScope, @@ -266,6 +267,7 @@ internal class ConversationViewModel @Inject constructor( isDeleteConversationConfirmationVisible = conversationMetadataDelegate.isDeleteConversationConfirmationVisible.value, isSubjectDialogVisible = conversationDraftDelegate.isSubjectDialogVisible.value, + linkConfirmation = conversationLinkDelegate.state.value, ), ) @@ -277,6 +279,7 @@ internal class ConversationViewModel @Inject constructor( attachmentLimitWarning: ConversationAttachmentLimitWarning?, isDeleteConversationConfirmationVisible: Boolean, isSubjectDialogVisible: Boolean, + linkConfirmation: ConversationLinkConfirmationUiState?, ): ConversationScreenScaffoldUiState { val isPresent = metadataState is ConversationMetadataUiState.Present val presentMetadata = metadataState as? ConversationMetadataUiState.Present @@ -294,6 +297,7 @@ internal class ConversationViewModel @Inject constructor( attachmentLimitWarning = attachmentLimitWarning, isDeleteConversationConfirmationVisible = isDeleteConversationConfirmationVisible, isSubjectDialogVisible = isSubjectDialogVisible, + linkConfirmation = linkConfirmation, metadata = metadataState, messages = messagesUiState, composer = composerUiState, @@ -365,6 +369,10 @@ internal class ConversationViewModel @Inject constructor( scope = viewModelScope, conversationIdFlow = conversationIdFlow, ) + conversationLinkDelegate.bind( + scope = viewModelScope, + conversationIdFlow = conversationIdFlow, + ) conversationSubscriptionSelectionDelegate.bind(scope = viewModelScope) bindDelegateStreams() } @@ -373,6 +381,7 @@ internal class ConversationViewModel @Inject constructor( viewModelScope.launch(defaultDispatcher) { merge( conversationDraftDelegate.effects, + conversationLinkDelegate.effects, conversationMediaPickerDelegate.effects, conversationMessageSelectionDelegate.effects, conversationMetadataDelegate.effects, @@ -602,14 +611,6 @@ internal class ConversationViewModel @Inject constructor( ) } - override fun onExternalUriClicked(uri: String) { - emitEffect( - ConversationScreenEffect.OpenExternalUri( - uri = uri, - ), - ) - } - override fun onMessageTextChanged(text: String, textRevision: Int) { conversationDraftDelegate.onMessageTextChanged( messageText = text, @@ -849,4 +850,5 @@ private data class ConversationScreenDialogUiState( val attachmentLimitWarning: ConversationAttachmentLimitWarning?, val isDeleteConversationConfirmationVisible: Boolean, val isSubjectDialogVisible: Boolean, + val linkConfirmation: ConversationLinkConfirmationUiState?, ) 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 868881cec..ae7e705a6 100644 --- a/src/com/android/messaging/ui/conversation/screen/model/ConversationScreenScaffoldUiState.kt +++ b/src/com/android/messaging/ui/conversation/screen/model/ConversationScreenScaffoldUiState.kt @@ -2,6 +2,7 @@ package com.android.messaging.ui.conversation.screen.model import androidx.compose.runtime.Immutable import com.android.messaging.ui.conversation.composer.model.ConversationComposerUiState +import com.android.messaging.ui.conversation.links.model.ConversationLinkConfirmationUiState import com.android.messaging.ui.conversation.messages.model.message.ConversationMessagesUiState import com.android.messaging.ui.conversation.metadata.model.ConversationMetadataUiState @@ -18,6 +19,7 @@ internal data class ConversationScreenScaffoldUiState( val attachmentLimitWarning: ConversationAttachmentLimitWarning? = null, val isDeleteConversationConfirmationVisible: Boolean = false, val isSubjectDialogVisible: Boolean = false, + val linkConfirmation: ConversationLinkConfirmationUiState? = null, val metadata: ConversationMetadataUiState = ConversationMetadataUiState.Loading, val messages: ConversationMessagesUiState = ConversationMessagesUiState.Loading, val composer: ConversationComposerUiState = ConversationComposerUiState(),