diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/PlanScreen.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/PlanScreen.kt
index 989260c286..ac0951c180 100644
--- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/PlanScreen.kt
+++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/PlanScreen.kt
@@ -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 = {},
),
)
diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/PlanViewModel.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/PlanViewModel.kt
index 3aa3b56986..6dbc8238c5 100644
--- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/PlanViewModel.kt
+++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/PlanViewModel.kt
@@ -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.
*/
diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/handlers/PlanHandlers.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/handlers/PlanHandlers.kt
index efd64d7dac..1f555d8705 100644
--- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/handlers/PlanHandlers.kt
+++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/handlers/PlanHandlers.kt
@@ -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)
},
diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/PlanScreenTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/PlanScreenTest.kt
index 39b587fa77..5ea6e6e569 100644
--- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/PlanScreenTest.kt
+++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/PlanScreenTest.kt
@@ -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(
diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/PlanViewModelTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/PlanViewModelTest.kt
index dcccce1334..39cccd18d3 100644
--- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/PlanViewModelTest.kt
+++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/PlanViewModelTest.kt
@@ -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,
)
}
diff --git a/ui/src/main/res/values/strings.xml b/ui/src/main/res/values/strings.xml
index fcf69300a7..21df3e1544 100644
--- a/ui/src/main/res/values/strings.xml
+++ b/ui/src/main/res/values/strings.xml
@@ -1302,6 +1302,7 @@ Do you want to switch to this account?
%1$s / year
%1$s / month
Manage plan
+ Manage your subscription plan in the Bitwarden web app.
Cancel Premium
Cancel now
You’ll continue to have Premium access until %1$s.