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.