mirror of
https://github.com/bitwarden/android.git
synced 2026-05-26 22:37:18 -05:00
[PM-37076] fix: Manage Plan launches web vault subscription URL (#6944)
This commit is contained in:
@@ -20,7 +20,10 @@ import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.rememberTopAppBarState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
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.graphics.Color
|
||||
@@ -99,6 +102,7 @@ fun PlanScreen(
|
||||
}
|
||||
|
||||
is PlanEvent.LaunchPortal -> intentManager.launchUri(event.url.toUri())
|
||||
is PlanEvent.LaunchUri -> intentManager.launchUri(event.url.toUri())
|
||||
PlanEvent.NavigateBack -> onNavigateBack()
|
||||
PlanEvent.NavigateToUpgradedToPremium -> onNavigateToUpgradedToPremium()
|
||||
}
|
||||
@@ -226,7 +230,7 @@ private fun PlanDialogs(
|
||||
message = stringResource(id = BitwardenString.trouble_loading_portal),
|
||||
confirmButtonText = stringResource(id = BitwardenString.try_again),
|
||||
dismissButtonText = stringResource(id = BitwardenString.close),
|
||||
onConfirmClick = handlers.onManagePlanClick,
|
||||
onConfirmClick = handlers.onRetryPortalClick,
|
||||
onDismissClick = handlers.onDismissPortalError,
|
||||
onDismissRequest = handlers.onDismissPortalError,
|
||||
)
|
||||
@@ -469,6 +473,7 @@ private fun PremiumContent(
|
||||
handlers: PlanHandlers,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var shouldShowManagePlanDialog by rememberSaveable { mutableStateOf(false) }
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
@@ -484,7 +489,7 @@ private fun PremiumContent(
|
||||
|
||||
BitwardenFilledButton(
|
||||
label = stringResource(id = BitwardenString.manage_plan),
|
||||
onClick = handlers.onManagePlanClick,
|
||||
onClick = { shouldShowManagePlanDialog = true },
|
||||
icon = rememberVectorPainter(id = BitwardenDrawable.ic_external_link),
|
||||
isExternalLink = true,
|
||||
modifier = Modifier
|
||||
@@ -510,6 +515,23 @@ private fun PremiumContent(
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Spacer(modifier = Modifier.navigationBarsPadding())
|
||||
}
|
||||
|
||||
if (shouldShowManagePlanDialog) {
|
||||
BitwardenTwoButtonDialog(
|
||||
title = stringResource(id = BitwardenString.continue_to_web_app),
|
||||
message = stringResource(
|
||||
id = BitwardenString.manage_your_subscription_plan_in_the_bitwarden_web_app,
|
||||
),
|
||||
confirmButtonText = stringResource(id = BitwardenString.continue_text),
|
||||
dismissButtonText = stringResource(id = BitwardenString.cancel),
|
||||
onConfirmClick = {
|
||||
shouldShowManagePlanDialog = false
|
||||
handlers.onManagePlanClick()
|
||||
},
|
||||
onDismissClick = { shouldShowManagePlanDialog = false },
|
||||
onDismissRequest = { shouldShowManagePlanDialog = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("LongMethod")
|
||||
@@ -741,6 +763,7 @@ private fun PlanScreenFreeCloudAccount_preview() {
|
||||
onConfirmCancelClick = {},
|
||||
onDismissCancelConfirmation = {},
|
||||
onDismissPortalError = {},
|
||||
onRetryPortalClick = {},
|
||||
onRetrySubscriptionClick = {},
|
||||
),
|
||||
)
|
||||
@@ -792,6 +815,7 @@ private fun PlanScreenPremiumAccount_preview() {
|
||||
onConfirmCancelClick = {},
|
||||
onDismissCancelConfirmation = {},
|
||||
onDismissPortalError = {},
|
||||
onRetryPortalClick = {},
|
||||
onRetrySubscriptionClick = {},
|
||||
),
|
||||
)
|
||||
|
||||
@@ -7,6 +7,7 @@ import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.bitwarden.core.data.util.toFormattedDateStyle
|
||||
import com.bitwarden.data.repository.model.Environment
|
||||
import com.bitwarden.data.repository.util.baseWebVaultUrlOrDefault
|
||||
import com.bitwarden.ui.platform.base.BaseViewModel
|
||||
import com.bitwarden.ui.platform.manager.intent.model.AuthTabData
|
||||
import com.bitwarden.ui.platform.resource.BitwardenDrawable
|
||||
@@ -169,6 +170,7 @@ class PlanViewModel @Inject constructor(
|
||||
is PlanAction.ConfirmCancelClick -> handleConfirmCancelClick()
|
||||
is PlanAction.DismissCancelConfirmation -> handleDismissCancelConfirmation()
|
||||
is PlanAction.DismissPortalError -> handleDismissPortalError()
|
||||
is PlanAction.RetryPortalClick -> handleRetryPortalClick()
|
||||
is PlanAction.RetrySubscriptionClick -> handleRetrySubscriptionClick()
|
||||
is PlanAction.Internal.CheckoutUrlReceive -> handleCheckoutUrlReceive(action)
|
||||
is PlanAction.Internal.UserStateUpdateReceive -> handleUserStateUpdateReceive(action)
|
||||
@@ -298,7 +300,11 @@ class PlanViewModel @Inject constructor(
|
||||
// region Premium user handlers
|
||||
|
||||
private fun handleManagePlanClick() {
|
||||
launchPortalFetch()
|
||||
val webVaultBaseUrl = environmentRepository
|
||||
.environment
|
||||
.environmentUrlData
|
||||
.baseWebVaultUrlOrDefault
|
||||
sendEvent(PlanEvent.LaunchUri(url = "$webVaultBaseUrl/#/settings/subscription/premium"))
|
||||
}
|
||||
|
||||
private fun handleCancelPremiumClick() {
|
||||
@@ -322,6 +328,10 @@ class PlanViewModel @Inject constructor(
|
||||
mutableStateFlow.update { it.copy(dialogState = null) }
|
||||
}
|
||||
|
||||
private fun handleRetryPortalClick() {
|
||||
launchPortalFetch()
|
||||
}
|
||||
|
||||
private fun handleDismissPortalError() {
|
||||
mutableStateFlow.update { it.copy(dialogState = null) }
|
||||
}
|
||||
@@ -876,6 +886,13 @@ sealed class PlanEvent {
|
||||
val url: String,
|
||||
) : PlanEvent()
|
||||
|
||||
/**
|
||||
* Launch the user's browser with the given web vault [url].
|
||||
*/
|
||||
data class LaunchUri(
|
||||
val url: String,
|
||||
) : PlanEvent()
|
||||
|
||||
/**
|
||||
* Navigate back to the previous screen.
|
||||
*/
|
||||
@@ -975,6 +992,11 @@ sealed class PlanAction {
|
||||
*/
|
||||
data object DismissPortalError : PlanAction()
|
||||
|
||||
/**
|
||||
* The user clicked retry on the portal error dialog.
|
||||
*/
|
||||
data object RetryPortalClick : PlanAction()
|
||||
|
||||
/**
|
||||
* The user clicked retry on the subscription error dialog.
|
||||
*/
|
||||
|
||||
@@ -24,6 +24,7 @@ data class PlanHandlers(
|
||||
val onConfirmCancelClick: () -> Unit,
|
||||
val onDismissCancelConfirmation: () -> Unit,
|
||||
val onDismissPortalError: () -> Unit,
|
||||
val onRetryPortalClick: () -> Unit,
|
||||
val onRetrySubscriptionClick: () -> Unit,
|
||||
) {
|
||||
@Suppress("UndocumentedPublicClass")
|
||||
@@ -58,6 +59,9 @@ data class PlanHandlers(
|
||||
onDismissPortalError = {
|
||||
viewModel.trySendAction(PlanAction.DismissPortalError)
|
||||
},
|
||||
onRetryPortalClick = {
|
||||
viewModel.trySendAction(PlanAction.RetryPortalClick)
|
||||
},
|
||||
onRetrySubscriptionClick = {
|
||||
viewModel.trySendAction(PlanAction.RetrySubscriptionClick)
|
||||
},
|
||||
|
||||
@@ -2,6 +2,9 @@ package com.x8bit.bitwarden.ui.platform.feature.premium.plan
|
||||
|
||||
import android.content.Intent
|
||||
import androidx.activity.result.ActivityResultLauncher
|
||||
import androidx.compose.ui.semantics.SemanticsProperties
|
||||
import androidx.compose.ui.semantics.getOrNull
|
||||
import androidx.compose.ui.test.SemanticsNodeInteraction
|
||||
import androidx.compose.ui.test.assertCountEquals
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.filterToOne
|
||||
@@ -11,11 +14,8 @@ import androidx.compose.ui.test.onAllNodesWithText
|
||||
import androidx.compose.ui.test.onNodeWithContentDescription
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.SemanticsNodeInteraction
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performScrollTo
|
||||
import androidx.compose.ui.semantics.SemanticsProperties
|
||||
import androidx.compose.ui.semantics.getOrNull
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.core.net.toUri
|
||||
import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow
|
||||
@@ -739,13 +739,69 @@ class PlanScreenTest : BitwardenComposeTest() {
|
||||
// region Action buttons
|
||||
|
||||
@Test
|
||||
fun `manage plan button click should send ManagePlanClick action`() {
|
||||
fun `manage plan button click should show continue to web app confirmation dialog`() {
|
||||
mutableStateFlow.update { it.copy(viewState = DEFAULT_PREMIUM_VIEW_STATE) }
|
||||
composeTestRule
|
||||
.onAllNodesWithText("Continue to web app?")
|
||||
.filterToOne(hasAnyAncestor(isDialog()))
|
||||
.assertDoesNotExist()
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithTag("ManagePlanButton")
|
||||
.performScrollTo()
|
||||
.performClick()
|
||||
|
||||
composeTestRule
|
||||
.onAllNodesWithText("Continue to web app?")
|
||||
.filterToOne(hasAnyAncestor(isDialog()))
|
||||
.assertExists()
|
||||
composeTestRule
|
||||
.onAllNodesWithText(
|
||||
"Manage your subscription plan in the Bitwarden web app.",
|
||||
)
|
||||
.filterToOne(hasAnyAncestor(isDialog()))
|
||||
.assertExists()
|
||||
verify(exactly = 0) { viewModel.trySendAction(PlanAction.ManagePlanClick) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `manage plan dialog continue click should send ManagePlanClick action and dismiss`() {
|
||||
mutableStateFlow.update { it.copy(viewState = DEFAULT_PREMIUM_VIEW_STATE) }
|
||||
composeTestRule
|
||||
.onNodeWithTag("ManagePlanButton")
|
||||
.performScrollTo()
|
||||
.performClick()
|
||||
|
||||
composeTestRule
|
||||
.onAllNodesWithText("Continue")
|
||||
.filterToOne(hasAnyAncestor(isDialog()))
|
||||
.performClick()
|
||||
|
||||
verify { viewModel.trySendAction(PlanAction.ManagePlanClick) }
|
||||
composeTestRule
|
||||
.onAllNodesWithText("Continue to web app?")
|
||||
.filterToOne(hasAnyAncestor(isDialog()))
|
||||
.assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `manage plan dialog cancel click should dismiss without sending action`() {
|
||||
mutableStateFlow.update { it.copy(viewState = DEFAULT_PREMIUM_VIEW_STATE) }
|
||||
composeTestRule
|
||||
.onNodeWithTag("ManagePlanButton")
|
||||
.performScrollTo()
|
||||
.performClick()
|
||||
|
||||
composeTestRule
|
||||
.onAllNodesWithText("Cancel")
|
||||
.filterToOne(hasAnyAncestor(isDialog()))
|
||||
.performClick()
|
||||
|
||||
verify(exactly = 0) { viewModel.trySendAction(PlanAction.ManagePlanClick) }
|
||||
composeTestRule
|
||||
.onAllNodesWithText("Continue to web app?")
|
||||
.filterToOne(hasAnyAncestor(isDialog()))
|
||||
.assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -955,6 +1011,21 @@ class PlanScreenTest : BitwardenComposeTest() {
|
||||
verify { viewModel.trySendAction(PlanAction.DismissPortalError) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `portal error dialog try again click should send RetryPortalClick action`() {
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
viewState = DEFAULT_PREMIUM_VIEW_STATE,
|
||||
dialogState = PlanState.DialogState.PortalError,
|
||||
)
|
||||
}
|
||||
composeTestRule
|
||||
.onAllNodesWithText("Try again")
|
||||
.filterToOne(hasAnyAncestor(isDialog()))
|
||||
.performClick()
|
||||
verify { viewModel.trySendAction(PlanAction.RetryPortalClick) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `cancel confirmation dialog should render when dialogState is CancelConfirmation`() {
|
||||
composeTestRule
|
||||
@@ -1078,6 +1149,17 @@ class PlanScreenTest : BitwardenComposeTest() {
|
||||
}
|
||||
|
||||
// endregion LaunchPortal event
|
||||
|
||||
// region LaunchUri event
|
||||
|
||||
@Test
|
||||
fun `LaunchUri event should call intentManager launchUri`() {
|
||||
val url = "https://vault.bitwarden.com/#/settings/subscription/premium"
|
||||
mutableEventFlow.tryEmit(PlanEvent.LaunchUri(url = url))
|
||||
verify { intentManager.launchUri(url.toUri()) }
|
||||
}
|
||||
|
||||
// endregion LaunchUri event
|
||||
}
|
||||
|
||||
private val DEFAULT_FREE_STATE = PlanState(
|
||||
|
||||
@@ -1302,59 +1302,45 @@ class PlanViewModelTest : BaseViewModelTest() {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ManagePlanClick should show LoadingPortal then emit LaunchPortal on success`() =
|
||||
runTest {
|
||||
markUserPremium()
|
||||
|
||||
val viewModel = createViewModel(
|
||||
subscriptionResult = SUBSCRIPTION_SUCCESS_ACTIVE,
|
||||
portalResult = CustomerPortalResult.Success(url = "https://portal"),
|
||||
)
|
||||
|
||||
viewModel.stateEventFlow(backgroundScope) { stateFlow, eventFlow ->
|
||||
assertEquals(DEFAULT_PREMIUM_LOADED_STATE, stateFlow.awaitItem())
|
||||
|
||||
viewModel.trySendAction(PlanAction.ManagePlanClick)
|
||||
|
||||
assertEquals(
|
||||
DEFAULT_PREMIUM_LOADED_STATE.copy(
|
||||
dialogState = PlanState.DialogState.LoadingPortal,
|
||||
),
|
||||
stateFlow.awaitItem(),
|
||||
)
|
||||
assertEquals(
|
||||
PlanEvent.LaunchPortal(url = "https://portal"),
|
||||
eventFlow.awaitItem(),
|
||||
)
|
||||
assertEquals(DEFAULT_PREMIUM_LOADED_STATE, stateFlow.awaitItem())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ManagePlanClick should show PortalError on failure`() = runTest {
|
||||
fun `ManagePlanClick should emit LaunchUri with web vault subscription URL`() = runTest {
|
||||
markUserPremium()
|
||||
|
||||
val viewModel = createViewModel(
|
||||
subscriptionResult = SUBSCRIPTION_SUCCESS_ACTIVE,
|
||||
portalResult = CustomerPortalResult.Error(error = RuntimeException("boom")),
|
||||
)
|
||||
val viewModel = createViewModel(subscriptionResult = SUBSCRIPTION_SUCCESS_ACTIVE)
|
||||
|
||||
viewModel.stateFlow.test {
|
||||
assertEquals(DEFAULT_PREMIUM_LOADED_STATE, awaitItem())
|
||||
viewModel.stateEventFlow(backgroundScope) { stateFlow, eventFlow ->
|
||||
assertEquals(DEFAULT_PREMIUM_LOADED_STATE, stateFlow.awaitItem())
|
||||
|
||||
viewModel.trySendAction(PlanAction.ManagePlanClick)
|
||||
|
||||
assertEquals(
|
||||
DEFAULT_PREMIUM_LOADED_STATE.copy(
|
||||
dialogState = PlanState.DialogState.LoadingPortal,
|
||||
PlanEvent.LaunchUri(
|
||||
url = "https://vault.bitwarden.com/#/settings/subscription/premium",
|
||||
),
|
||||
awaitItem(),
|
||||
eventFlow.awaitItem(),
|
||||
)
|
||||
}
|
||||
coVerify(exactly = 0) { mockBillingRepository.getPortalUrl() }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ManagePlanClick should fall back to base URL when webVault is null`() = runTest {
|
||||
markUserPremium()
|
||||
every { mockEnvironmentRepository.environment } returns Environment.SelfHosted(
|
||||
environmentUrlData = EnvironmentUrlDataJson(base = "https://self-hosted.example"),
|
||||
)
|
||||
|
||||
val viewModel = createViewModel(subscriptionResult = SUBSCRIPTION_SUCCESS_ACTIVE)
|
||||
|
||||
viewModel.stateEventFlow(backgroundScope) { stateFlow, eventFlow ->
|
||||
assertEquals(DEFAULT_PREMIUM_LOADED_STATE, stateFlow.awaitItem())
|
||||
|
||||
viewModel.trySendAction(PlanAction.ManagePlanClick)
|
||||
|
||||
assertEquals(
|
||||
DEFAULT_PREMIUM_LOADED_STATE.copy(
|
||||
dialogState = PlanState.DialogState.PortalError,
|
||||
PlanEvent.LaunchUri(
|
||||
url = "https://self-hosted.example/#/settings/subscription/premium",
|
||||
),
|
||||
awaitItem(),
|
||||
eventFlow.awaitItem(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1409,6 +1395,64 @@ class PlanViewModelTest : BaseViewModelTest() {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `RetryPortalClick should show LoadingPortal then emit LaunchPortal on success`() =
|
||||
runTest {
|
||||
markUserPremium()
|
||||
|
||||
val viewModel = createViewModel(
|
||||
subscriptionResult = SUBSCRIPTION_SUCCESS_ACTIVE,
|
||||
portalResult = CustomerPortalResult.Success(url = "https://portal"),
|
||||
)
|
||||
|
||||
viewModel.stateEventFlow(backgroundScope) { stateFlow, eventFlow ->
|
||||
assertEquals(DEFAULT_PREMIUM_LOADED_STATE, stateFlow.awaitItem())
|
||||
|
||||
viewModel.trySendAction(PlanAction.RetryPortalClick)
|
||||
|
||||
assertEquals(
|
||||
DEFAULT_PREMIUM_LOADED_STATE.copy(
|
||||
dialogState = PlanState.DialogState.LoadingPortal,
|
||||
),
|
||||
stateFlow.awaitItem(),
|
||||
)
|
||||
assertEquals(
|
||||
PlanEvent.LaunchPortal(url = "https://portal"),
|
||||
eventFlow.awaitItem(),
|
||||
)
|
||||
assertEquals(DEFAULT_PREMIUM_LOADED_STATE, stateFlow.awaitItem())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `RetryPortalClick should show PortalError on failure`() = runTest {
|
||||
markUserPremium()
|
||||
|
||||
val viewModel = createViewModel(
|
||||
subscriptionResult = SUBSCRIPTION_SUCCESS_ACTIVE,
|
||||
portalResult = CustomerPortalResult.Error(error = RuntimeException("boom")),
|
||||
)
|
||||
|
||||
viewModel.stateFlow.test {
|
||||
assertEquals(DEFAULT_PREMIUM_LOADED_STATE, awaitItem())
|
||||
|
||||
viewModel.trySendAction(PlanAction.RetryPortalClick)
|
||||
|
||||
assertEquals(
|
||||
DEFAULT_PREMIUM_LOADED_STATE.copy(
|
||||
dialogState = PlanState.DialogState.LoadingPortal,
|
||||
),
|
||||
awaitItem(),
|
||||
)
|
||||
assertEquals(
|
||||
DEFAULT_PREMIUM_LOADED_STATE.copy(
|
||||
dialogState = PlanState.DialogState.PortalError,
|
||||
),
|
||||
awaitItem(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun markUserPremium() {
|
||||
mutableUserStateFlow.value = DEFAULT_USER_STATE.copy(
|
||||
accounts = listOf(DEFAULT_ACCOUNT.copy(isPremium = true)),
|
||||
@@ -1450,9 +1494,9 @@ class PlanViewModelTest : BaseViewModelTest() {
|
||||
authRepository = mockAuthRepository,
|
||||
billingRepository = mockBillingRepository,
|
||||
premiumStateManager = mockPremiumStateManager,
|
||||
environmentRepository = mockEnvironmentRepository,
|
||||
specialCircumstanceManager = mockSpecialCircumstanceManager,
|
||||
vaultRepository = mockVaultRepository,
|
||||
environmentRepository = mockEnvironmentRepository,
|
||||
clock = clock,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1302,6 +1302,7 @@ Do you want to switch to this account?</string>
|
||||
<string name="billing_rate_per_year">%1$s / year</string>
|
||||
<string name="billing_rate_per_month">%1$s / month</string>
|
||||
<string name="manage_plan">Manage plan</string>
|
||||
<string name="manage_your_subscription_plan_in_the_bitwarden_web_app">Manage your subscription plan in the Bitwarden web app.</string>
|
||||
<string name="cancel_premium">Cancel Premium</string>
|
||||
<string name="cancel_now">Cancel now</string>
|
||||
<string name="cancel_premium_confirmation">You’ll continue to have Premium access until %1$s.</string>
|
||||
|
||||
Reference in New Issue
Block a user