From ed785d3117752c9697054d7db79ba7e7c959c277 Mon Sep 17 00:00:00 2001 From: David Perez Date: Wed, 1 Apr 2026 10:43:13 -0500 Subject: [PATCH] PM-34498: Update attachments premium dialogs --- .../feature/attachments/AttachmentsScreen.kt | 20 ++- .../attachments/AttachmentsViewModel.kt | 51 +++++-- .../handlers/AttachmentsHandlers.kt | 4 + .../attachments/AttachmentsScreenTest.kt | 35 +++++ .../attachments/AttachmentsViewModelTest.kt | 127 +++++++++++------- 5 files changed, 176 insertions(+), 61 deletions(-) diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/attachments/AttachmentsScreen.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/attachments/AttachmentsScreen.kt index 3343d926581..e28ebad76a0 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/attachments/AttachmentsScreen.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/attachments/AttachmentsScreen.kt @@ -11,6 +11,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource +import androidx.core.net.toUri import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.bitwarden.ui.platform.base.util.EventsEffect @@ -21,6 +22,7 @@ import com.bitwarden.ui.platform.components.content.BitwardenErrorContent import com.bitwarden.ui.platform.components.content.BitwardenLoadingContent import com.bitwarden.ui.platform.components.dialog.BitwardenBasicDialog import com.bitwarden.ui.platform.components.dialog.BitwardenLoadingDialog +import com.bitwarden.ui.platform.components.dialog.BitwardenTwoButtonDialog import com.bitwarden.ui.platform.components.scaffold.BitwardenScaffold import com.bitwarden.ui.platform.components.snackbar.BitwardenSnackbarHost import com.bitwarden.ui.platform.components.snackbar.model.rememberBitwardenSnackbarHostState @@ -54,7 +56,7 @@ fun AttachmentsScreen( EventsEffect(viewModel = viewModel) { event -> when (event) { AttachmentsEvent.NavigateBack -> onNavigateBack() - + is AttachmentsEvent.NavigateToUri -> intentManager.launchUri(event.uri.toUri()) AttachmentsEvent.ShowChooserSheet -> { fileChooserLauncher.launch( intentManager.createFileChooserIntent(withCameraIntents = false), @@ -70,7 +72,7 @@ fun AttachmentsScreen( AttachmentsDialogs( dialogState = state.dialogState, - onDismissRequest = attachmentsHandlers.onDismissRequest, + attachmentsHandlers = attachmentsHandlers, ) val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState()) @@ -123,13 +125,23 @@ fun AttachmentsScreen( @Composable private fun AttachmentsDialogs( dialogState: AttachmentsState.DialogState?, - onDismissRequest: () -> Unit, + attachmentsHandlers: AttachmentsHandlers, ) { when (dialogState) { + AttachmentsState.DialogState.RequiresPremium -> BitwardenTwoButtonDialog( + title = stringResource(id = BitwardenString.attachments_unavailable), + message = stringResource(id = BitwardenString.attachments_are_a_premium_feature), + confirmButtonText = stringResource(id = BitwardenString.upgrade_to_premium), + onConfirmClick = attachmentsHandlers.onUpgradeToPremiumClick, + dismissButtonText = stringResource(id = BitwardenString.cancel), + onDismissClick = attachmentsHandlers.onDismissRequest, + onDismissRequest = attachmentsHandlers.onDismissRequest, + ) + is AttachmentsState.DialogState.Error -> BitwardenBasicDialog( title = dialogState.title?.invoke(), message = dialogState.message(), - onDismissRequest = onDismissRequest, + onDismissRequest = attachmentsHandlers.onDismissRequest, throwable = dialogState.throwable, ) diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/attachments/AttachmentsViewModel.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/attachments/AttachmentsViewModel.kt index adb13a5a2a2..5e09efd51c8 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/attachments/AttachmentsViewModel.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/attachments/AttachmentsViewModel.kt @@ -6,6 +6,7 @@ import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope import com.bitwarden.core.data.manager.model.FlagKey import com.bitwarden.core.data.repository.model.DataState +import com.bitwarden.data.repository.util.baseWebVaultUrlOrDefault import com.bitwarden.ui.platform.base.BaseViewModel import com.bitwarden.ui.platform.components.snackbar.model.BitwardenSnackbarData import com.bitwarden.ui.platform.model.FileData @@ -17,6 +18,7 @@ import com.bitwarden.vault.CipherView import com.x8bit.bitwarden.data.auth.repository.AuthRepository import com.x8bit.bitwarden.data.auth.repository.model.UserState import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager +import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository import com.x8bit.bitwarden.data.vault.repository.VaultRepository import com.x8bit.bitwarden.data.vault.repository.model.CreateAttachmentResult import com.x8bit.bitwarden.data.vault.repository.model.DeleteAttachmentResult @@ -46,6 +48,7 @@ private const val MAX_FILE_SIZE_BYTES: Long = 100 * 1024 * 1024 @HiltViewModel class AttachmentsViewModel @Inject constructor( private val authRepo: AuthRepository, + private val environmentRepo: EnvironmentRepository, private val vaultRepo: VaultRepository, featureFlagManager: FeatureFlagManager, savedStateHandle: SavedStateHandle, @@ -57,10 +60,7 @@ class AttachmentsViewModel @Inject constructor( AttachmentsState( cipherId = savedStateHandle.toAttachmentsArgs().cipherId, viewState = AttachmentsState.ViewState.Loading, - dialogState = AttachmentsState.DialogState.Error( - title = null, - message = BitwardenString.premium_required.asText(), - ) + dialogState = AttachmentsState.DialogState.RequiresPremium .takeUnless { isPremiumUser }, isPremiumUser = isPremiumUser, isAttachmentUpdatesEnabled = featureFlagManager.getFeatureFlag( @@ -94,6 +94,7 @@ class AttachmentsViewModel @Inject constructor( AttachmentsAction.BackClick -> handleBackClick() AttachmentsAction.SaveClick -> handleSaveClick() AttachmentsAction.DismissDialogClick -> handleDismissDialogClick() + AttachmentsAction.UpgradeToPremiumClick -> handleUpgradeToPremiumClick() AttachmentsAction.ChooseFileClick -> handleChooseFileClick() is AttachmentsAction.FileNameChange -> handleFileNameChange(action) is AttachmentsAction.FileChoose -> handleFileChoose(action) @@ -111,12 +112,7 @@ class AttachmentsViewModel @Inject constructor( onContent { content -> if (!state.isPremiumUser) { mutableStateFlow.update { - it.copy( - dialogState = AttachmentsState.DialogState.Error( - title = BitwardenString.an_error_has_occurred.asText(), - message = BitwardenString.premium_required.asText(), - ), - ) + it.copy(dialogState = AttachmentsState.DialogState.RequiresPremium) } return@onContent } @@ -170,6 +166,19 @@ class AttachmentsViewModel @Inject constructor( mutableStateFlow.update { it.copy(dialogState = null) } } + private fun handleUpgradeToPremiumClick() { + mutableStateFlow.update { it.copy(dialogState = null) } + val baseUrl = environmentRepo + .environment + .environmentUrlData + .baseWebVaultUrlOrDefault + sendEvent( + AttachmentsEvent.NavigateToUri( + uri = "$baseUrl/#/settings/subscription/premium?callToAction=upgradeToPremium", + ), + ) + } + private fun handleChooseFileClick() { sendEvent(AttachmentsEvent.ShowChooserSheet) } @@ -227,6 +236,12 @@ class AttachmentsViewModel @Inject constructor( } private fun handleItemClick(action: AttachmentsAction.ItemClick) { + if (!state.isPremiumUser) { + mutableStateFlow.update { + it.copy(dialogState = AttachmentsState.DialogState.RequiresPremium) + } + return + } sendEvent( AttachmentsEvent.NavigateToPreview( cipherId = state.cipherId, @@ -465,6 +480,12 @@ data class AttachmentsState( * Represents the current state of any dialogs on the screen. */ sealed class DialogState : Parcelable { + /** + * Represents a dismissible dialog indicating that you must have premium. + */ + @Parcelize + data object RequiresPremium : DialogState() + /** * Represents a dismissible dialog with the given error [message]. */ @@ -494,6 +515,11 @@ sealed class AttachmentsEvent { */ data object NavigateBack : AttachmentsEvent() + /** + * Navigates to upgrade to the given Uri. + */ + data class NavigateToUri(val uri: String) : AttachmentsEvent() + /** * Navigates to preview the attachment. */ @@ -549,6 +575,11 @@ sealed class AttachmentsAction { */ data object DismissDialogClick : AttachmentsAction() + /** + * User clicked to upgrade top Premium. + */ + data object UpgradeToPremiumClick : AttachmentsAction() + /** * User clicked to select a new attachment file. */ diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/attachments/handlers/AttachmentsHandlers.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/attachments/handlers/AttachmentsHandlers.kt index d3fe04d4d7e..e66af3ba836 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/attachments/handlers/AttachmentsHandlers.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/attachments/handlers/AttachmentsHandlers.kt @@ -17,6 +17,7 @@ data class AttachmentsHandlers( val onItemClick: (attachment: AttachmentsState.AttachmentItem) -> Unit, val onDismissRequest: () -> Unit, val onFileNameChange: (String) -> Unit, + val onUpgradeToPremiumClick: () -> Unit, ) { @Suppress("UndocumentedPublicClass") companion object { @@ -38,6 +39,9 @@ data class AttachmentsHandlers( onFileNameChange = { viewModel.trySendAction(AttachmentsAction.FileNameChange(it)) }, + onUpgradeToPremiumClick = { + viewModel.trySendAction(AttachmentsAction.UpgradeToPremiumClick) + }, ) } } diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/attachments/AttachmentsScreenTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/attachments/AttachmentsScreenTest.kt index 745e9107103..c1fde06aa12 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/attachments/AttachmentsScreenTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/attachments/AttachmentsScreenTest.kt @@ -11,6 +11,7 @@ import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performTextInput +import androidx.core.net.toUri import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow import com.bitwarden.ui.platform.manager.IntentManager import com.bitwarden.ui.util.asText @@ -64,6 +65,15 @@ class AttachmentsScreenTest : BitwardenComposeTest() { assertTrue(onNavigateBackCalled) } + @Test + fun `NavigateToUri should call launchUri`() { + val uriString = "https://www.bitwarden.com" + mutableEventFlow.tryEmit(AttachmentsEvent.NavigateToUri(uri = uriString)) + verify(exactly = 1) { + intentManager.launchUri(uriString.toUri()) + } + } + @Test fun `NavigateToPreview should call onNavigateToPreview`() { mutableEventFlow.tryEmit( @@ -240,6 +250,31 @@ class AttachmentsScreenTest : BitwardenComposeTest() { } } + @Test + fun `requires Premium dialog should be displayed according to state`() { + val requiresPremiumMessage = "Attachments unavailable" + composeTestRule.onNode(isDialog()).assertDoesNotExist() + composeTestRule.onNodeWithText(requiresPremiumMessage).assertDoesNotExist() + + mutableStateFlow.update { + it.copy(dialogState = AttachmentsState.DialogState.RequiresPremium) + } + + composeTestRule + .onNodeWithText(requiresPremiumMessage) + .assert(hasAnyAncestor(isDialog())) + .assertIsDisplayed() + + composeTestRule + .onNodeWithText("Upgrade to Premium") + .assert(hasAnyAncestor(isDialog())) + .performClick() + + verify(exactly = 1) { + viewModel.trySendAction(AttachmentsAction.UpgradeToPremiumClick) + } + } + @Test fun `error dialog should be displayed according to state`() { val errorMessage = "Fail" diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/attachments/AttachmentsViewModelTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/attachments/AttachmentsViewModelTest.kt index 157230213e1..5c4ed7348ed 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/attachments/AttachmentsViewModelTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/attachments/AttachmentsViewModelTest.kt @@ -18,6 +18,7 @@ import com.x8bit.bitwarden.data.auth.repository.model.UserState import com.x8bit.bitwarden.data.platform.error.NoActiveUserException import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager import com.x8bit.bitwarden.data.platform.manager.model.FirstTimeState +import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockCipherView import com.x8bit.bitwarden.data.vault.repository.VaultRepository import com.x8bit.bitwarden.data.vault.repository.model.CreateAttachmentResult @@ -29,6 +30,7 @@ import io.mockk.every import io.mockk.mockk import io.mockk.mockkStatic import io.mockk.unmockkStatic +import io.mockk.verify import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update @@ -44,6 +46,9 @@ class AttachmentsViewModelTest : BaseViewModelTest() { private val authRepository: AuthRepository = mockk { every { userStateFlow } returns mutableUserStateFlow } + private val environmentRepository: EnvironmentRepository = mockk { + every { environment } returns Environment.Us + } private val mutableVaultItemStateFlow = MutableStateFlow>(DataState.Loading) private val vaultRepository: VaultRepository = mockk { @@ -98,7 +103,30 @@ class AttachmentsViewModelTest : BaseViewModelTest() { } @Test - fun `ItemClick should emit NavigateToPreview`() = runTest { + fun `ItemClick should display RequiresPremium dialog when user is not Premium`() = runTest { + mutableUserStateFlow.update { + DEFAULT_USER_STATE.copy( + accounts = listOf(DEFAULT_ACCOUNT.copy(isPremium = false)), + ) + } + val viewModel = createViewModel() + viewModel.eventFlow.test { + viewModel.trySendAction( + AttachmentsAction.ItemClick(attachment = DEFAULT_ATTACHMENT_ITEM), + ) + expectNoEvents() + } + assertEquals( + DEFAULT_STATE.copy( + dialogState = AttachmentsState.DialogState.RequiresPremium, + isPremiumUser = false, + ), + viewModel.stateFlow.value, + ) + } + + @Test + fun `ItemClick should emit NavigateToPreview when user is Premium`() = runTest { val viewModel = createViewModel() viewModel.eventFlow.test { viewModel.trySendAction( @@ -115,6 +143,24 @@ class AttachmentsViewModelTest : BaseViewModelTest() { } } + @Test + fun `UpgradeToPremiumClick should emit NavigateToUri`() = runTest { + val viewModel = createViewModel() + viewModel.eventFlow.test { + viewModel.trySendAction(AttachmentsAction.UpgradeToPremiumClick) + assertEquals( + AttachmentsEvent.NavigateToUri( + uri = "https://vault.bitwarden.com/#/settings/subscription" + + "/premium?callToAction=upgradeToPremium", + ), + awaitItem(), + ) + } + verify(exactly = 1) { + environmentRepository.environment + } + } + @Test fun `FileNameChange should update the newAttachment state`() = runTest { val cipherView = createMockCipherView(number = 1) @@ -157,32 +203,19 @@ class AttachmentsViewModelTest : BaseViewModelTest() { @Test fun `SaveClick should display error dialog when user is not Premium`() = runTest { val cipherView = createMockCipherView(number = 1) - val state = DEFAULT_STATE.copy( - viewState = DEFAULT_CONTENT_WITH_ATTACHMENTS, - dialogState = AttachmentsState.DialogState.Error( - title = null, - message = BitwardenString.premium_required.asText(), - throwable = null, - ), - isPremiumUser = false, - ) mutableVaultItemStateFlow.value = DataState.Loaded(cipherView) mutableUserStateFlow.value = null val viewModel = createViewModel() - viewModel.stateFlow.test { - assertEquals(state, awaitItem()) - viewModel.trySendAction(AttachmentsAction.SaveClick) - assertEquals( - state.copy( - dialogState = AttachmentsState.DialogState.Error( - title = BitwardenString.an_error_has_occurred.asText(), - message = BitwardenString.premium_required.asText(), - throwable = null, - ), - ), - awaitItem(), - ) - } + + viewModel.trySendAction(AttachmentsAction.SaveClick) + assertEquals( + DEFAULT_STATE.copy( + viewState = DEFAULT_CONTENT_WITH_ATTACHMENTS, + dialogState = AttachmentsState.DialogState.RequiresPremium, + isPremiumUser = false, + ), + viewModel.stateFlow.value, + ) } @Test @@ -766,6 +799,7 @@ class AttachmentsViewModelTest : BaseViewModelTest() { initialState: AttachmentsState? = null, ): AttachmentsViewModel = AttachmentsViewModel( authRepo = authRepository, + environmentRepo = environmentRepository, vaultRepo = vaultRepository, featureFlagManager = featureFlagManager, savedStateHandle = SavedStateHandle().apply { @@ -777,31 +811,30 @@ class AttachmentsViewModelTest : BaseViewModelTest() { ) } +private val DEFAULT_ACCOUNT = UserState.Account( + userId = "mockUserId-1", + name = "Active User", + email = "active@bitwarden.com", + environment = Environment.Us, + avatarColorHex = "#aa00aa", + isPremium = true, + isLoggedIn = true, + isVaultUnlocked = true, + needsPasswordReset = false, + isBiometricsEnabled = false, + organizations = emptyList(), + needsMasterPassword = false, + trustedDevice = null, + hasMasterPassword = true, + isUsingKeyConnector = false, + onboardingStatus = OnboardingStatus.COMPLETE, + firstTimeState = FirstTimeState(showImportLoginsCard = true), + isExportable = true, + creationDate = null, +) private val DEFAULT_USER_STATE = UserState( activeUserId = "mockUserId-1", - accounts = listOf( - UserState.Account( - userId = "mockUserId-1", - name = "Active User", - email = "active@bitwarden.com", - environment = Environment.Us, - avatarColorHex = "#aa00aa", - isPremium = true, - isLoggedIn = true, - isVaultUnlocked = true, - needsPasswordReset = false, - isBiometricsEnabled = false, - organizations = emptyList(), - needsMasterPassword = false, - trustedDevice = null, - hasMasterPassword = true, - isUsingKeyConnector = false, - onboardingStatus = OnboardingStatus.COMPLETE, - firstTimeState = FirstTimeState(showImportLoginsCard = true), - isExportable = true, - creationDate = null, - ), - ), + accounts = listOf(DEFAULT_ACCOUNT), ) private val DEFAULT_STATE: AttachmentsState = AttachmentsState(