mirror of
https://github.com/bitwarden/android.git
synced 2026-08-29 10:17:56 -05:00
PM-34498: bug: Update attachments premium dialogs (#6753)
This commit is contained in:
+16
-4
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
+41
-10
@@ -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.
|
||||
*/
|
||||
|
||||
+4
@@ -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)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+35
@@ -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"
|
||||
|
||||
+80
-47
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user