PM-34498: bug: Update attachments premium dialogs (#6753)

This commit is contained in:
David Perez
2026-04-01 17:47:19 +00:00
committed by GitHub
parent e404477059
commit ab583296aa
5 changed files with 176 additions and 61 deletions
@@ -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,
)
@@ -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.
*/
@@ -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)
},
)
}
}
@@ -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"
@@ -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<CipherView?>>(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(