Compare commits

..

2 Commits

Author SHA1 Message Date
Amy Galles
1ef60f4a5f Merge branch 'main' into agalles/create-github-workflow-trigger 2026-06-18 16:39:02 -07:00
Amy Galles
4c915e1c27 create workflow trigger for github publish 2026-06-18 16:38:14 -07:00
6 changed files with 453 additions and 625 deletions

View File

@@ -16,279 +16,17 @@ env:
ARTIFACTS_PATH: artifacts
jobs:
create-release:
name: Create GitHub Release
jobs:
deploy:
name: Trigger publish via deploy repo
runs-on: ubuntu-24.04
permissions:
contents: write
id-token: write
steps:
- name: Check out repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Trigger publish
uses: bitwarden/gh-actions/trigger-actions@main
with:
fetch-depth: 0
persist-credentials: true
- name: Log inputs to job summary
uses: ./.github/actions/log-inputs
with:
inputs: ${{ toJson(inputs) }}
- name: Get branch from workflow run
id: get_release_branch
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ARTIFACT_RUN_ID: ${{ inputs.artifact-run-id }}
run: |
workflow_data=$(gh run view "$ARTIFACT_RUN_ID" --json headBranch,workflowName)
release_branch=$(echo "$workflow_data" | jq -r .headBranch)
workflow_name=$(echo "$workflow_data" | jq -r .workflowName)
# branch protection check
if [[ "$release_branch" != "main" && ! "$release_branch" =~ ^release/ ]]; then
echo "::error::Branch '$release_branch' is not 'main' or a release branch starting with 'release/'. Releases must be created from protected branches."
exit 1
fi
echo "🔖 Release branch: $release_branch"
echo "🔖 Workflow name: $workflow_name"
echo "release_branch=$release_branch" >> "$GITHUB_OUTPUT"
echo "workflow_name=$workflow_name" >> "$GITHUB_OUTPUT"
case "$workflow_name" in
*"Password Manager"* | "Build")
app_name="Password Manager"
app_name_suffix="bwpm"
;;
*"Authenticator"*)
app_name="Authenticator"
app_name_suffix="bwa"
;;
*)
echo "::error::Unknown workflow name: $workflow_name"
exit 1
;;
esac
echo "🔖 App name: $app_name"
echo "🔖 App name suffix: $app_name_suffix"
echo "app_name=$app_name" >> "$GITHUB_OUTPUT"
echo "app_name_suffix=$app_name_suffix" >> "$GITHUB_OUTPUT"
- name: Get version info from run logs and set release tag name
id: get_release_info
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ARTIFACT_RUN_ID: ${{ inputs.artifact-run-id }}
_APP_NAME_SUFFIX: ${{ steps.get_release_branch.outputs.app_name_suffix }}
run: |
workflow_log=$(gh run view "$ARTIFACT_RUN_ID" --log)
version_number_with_trailing_dot=$(grep -m 1 "Setting version code to" <<< "$workflow_log" | sed 's/.*Setting version code to //')
version_number=${version_number_with_trailing_dot%.} # remove trailing dot
version_name_with_trailing_dot=$(grep -m 1 "Setting version name to" <<< "$workflow_log" | sed 's/.*Setting version name to //')
version_name=${version_name_with_trailing_dot%.} # remove trailing dot
if [[ -z "$version_name" ]]; then
echo "::warning::Version name not found. Using default value - 0.0.0"
version_name="0.0.0"
else
echo "✅ Found version name: $version_name"
fi
if [[ -z "$version_number" ]]; then
echo "::warning::Version number not found. Using default value - 0"
version_number="0"
else
echo "✅ Found version number: $version_number"
fi
echo "version_number=$version_number" >> "$GITHUB_OUTPUT"
echo "version_name=$version_name" >> "$GITHUB_OUTPUT"
tag_name="v$version_name-$_APP_NAME_SUFFIX" # e.g. v2025.6.0-bwpm
echo "🔖 New tag name: $tag_name"
echo "tag_name=$tag_name" >> "$GITHUB_OUTPUT"
last_release_tag=$(git tag -l --sort=-authordate | grep "$_APP_NAME_SUFFIX" | head -n 1)
echo "🔖 Last release tag: $last_release_tag"
echo "last_release_tag=$last_release_tag" >> "$GITHUB_OUTPUT"
- name: Download artifacts
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ARTIFACT_RUN_ID: ${{ inputs.artifact-run-id }}
run: |
gh run download "$ARTIFACT_RUN_ID" -D "$ARTIFACTS_PATH"
file_count=$(find "$ARTIFACTS_PATH" -type f | wc -l)
echo "Downloaded $file_count file(s)."
if [ "$file_count" -gt 0 ]; then
echo "Downloaded files:"
find "$ARTIFACTS_PATH" -type f
fi
# Files that won't be included in any release
files_to_remove=(
"com.x8bit.bitwarden.aab"
"com.x8bit.bitwarden.aab-sha256.txt"
"com.x8bit.bitwarden.beta.apk"
"com.x8bit.bitwarden.beta.apk-sha256.txt"
"com.x8bit.bitwarden.beta.aab"
"com.x8bit.bitwarden.beta.aab-sha256.txt"
"com.x8bit.bitwarden.beta-fdroid.apk"
"com.x8bit.bitwarden.beta-fdroid.apk-sha256.txt"
"com.x8bit.bitwarden.dev.apk"
"com.x8bit.bitwarden.dev.apk-sha256.txt"
"com.bitwarden.authenticator.aab"
"authenticator-android-aab-sha256.txt"
)
for file in "${files_to_remove[@]}"; do
find "$ARTIFACTS_PATH" -name "$file" -type f -delete
done
echo "🔖 Removed internal artifacts."
echo ""
echo "🔖 Files to be included in the release:"
find "$ARTIFACTS_PATH" -type f
- name: Log in to Azure
uses: bitwarden/gh-actions/azure-login@main
with:
subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
tenant_id: ${{ secrets.AZURE_TENANT_ID }}
client_id: ${{ secrets.AZURE_CLIENT_ID }}
- name: Get Azure Key Vault secrets
id: get-kv-secrets
uses: bitwarden/gh-actions/get-keyvault-secrets@main
with:
keyvault: gh-android
secrets: "JIRA-API-EMAIL,JIRA-API-TOKEN,JIRA-CLOUD-ID"
- name: Log out from Azure
uses: bitwarden/gh-actions/azure-logout@main
- name: Get product release notes
id: get_release_notes
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ARTIFACT_RUN_ID: ${{ inputs.artifact-run-id }}
_VERSION_NAME: ${{ steps.get_release_info.outputs.version_name }}
_RELEASE_TICKET_ID: ${{ inputs.release-ticket-id }}
_JIRA_CLOUD_ID: ${{ steps.get-kv-secrets.outputs.JIRA-CLOUD-ID }}
_JIRA_API_EMAIL: ${{ steps.get-kv-secrets.outputs.JIRA-API-EMAIL }}
_JIRA_API_TOKEN: ${{ steps.get-kv-secrets.outputs.JIRA-API-TOKEN }}
run: |
echo "Getting product release notes..."
# capture output and exit code so this step continues even if we can't retrieve release notes.
script_exit_code=0
product_release_notes=$(python3 .github/scripts/jira-get-release-notes/jira_release_notes.py "$_RELEASE_TICKET_ID" "$_JIRA_CLOUD_ID" "$_JIRA_API_EMAIL" "$_JIRA_API_TOKEN") || script_exit_code=$?
echo "--------------------------------"
if [[ $script_exit_code -ne 0 || -z "$product_release_notes" ]]; then
echo "Script Output: $product_release_notes"
echo "::warning::Failed to fetch release notes from Jira. Check script logs for more details."
product_release_notes="<insert product release notes here>"
else
echo "✅ Product release notes:"
echo "$product_release_notes"
fi
echo "$product_release_notes" > product_release_notes.txt
- name: Create Release
id: create_release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
_APP_NAME: ${{ steps.get_release_branch.outputs.app_name }}
_VERSION_NAME: ${{ steps.get_release_info.outputs.version_name }}
_VERSION_NUMBER: ${{ steps.get_release_info.outputs.version_number }}
_TARGET_COMMIT: ${{ steps.get_release_branch.outputs.release_branch }}
_TAG_NAME: ${{ steps.get_release_info.outputs.tag_name }}
_LAST_RELEASE_TAG: ${{ steps.get_release_info.outputs.last_release_tag }}
run: |
is_latest_release=false
if [[ "$_APP_NAME" == "Password Manager" ]]; then
is_latest_release=true
fi
echo "⌛️ Creating release for $_APP_NAME $_VERSION_NAME ($_VERSION_NUMBER) on $_TARGET_COMMIT"
release_url=$(gh release create "$_TAG_NAME" \
--title "$_APP_NAME $_VERSION_NAME ($_VERSION_NUMBER)" \
--target "$_TARGET_COMMIT" \
--generate-notes \
--notes-start-tag "$_LAST_RELEASE_TAG" \
--latest=$is_latest_release \
--draft \
"$ARTIFACTS_PATH/*/*")
# Extract release tag from URL
release_id_from_url=$(echo "$release_url" | sed 's/.*\/tag\///')
echo "release_id_from_url=$release_id_from_url" >> "$GITHUB_OUTPUT"
echo "url=$release_url" >> "$GITHUB_OUTPUT"
echo "✅ Release created: $release_url"
echo "🔖 Release ID from URL: $release_id_from_url"
- name: Update Release Description
id: update_release_description
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ARTIFACT_RUN_ID: ${{ inputs.artifact-run-id }}
_VERSION_NAME: ${{ steps.get_release_info.outputs.version_name }}
_RELEASE_ID: ${{ steps.create_release.outputs.release_id_from_url }}
run: |
echo "Getting current release body. Release ID: $_RELEASE_ID"
current_body=$(gh release view "$_RELEASE_ID" --json body --jq .body)
product_release_notes=$(cat product_release_notes.txt)
# Update release description with product release notes and builds source
updated_body="# Overview
${product_release_notes}
${current_body}
**Builds Source:** https://github.com/${{ github.repository }}/actions/runs/$ARTIFACT_RUN_ID"
new_release_url=$(gh release edit "$_RELEASE_ID" --notes "$updated_body")
# draft release links change after editing
echo "release_url=$new_release_url" >> "$GITHUB_OUTPUT"
- name: Add Release Summary
env:
_RELEASE_TAG: ${{ steps.get_release_info.outputs.tag_name }}
_LAST_RELEASE_TAG: ${{ steps.get_release_info.outputs.last_release_tag }}
_VERSION_NAME: ${{ steps.get_release_info.outputs.version_name }}
_VERSION_NUMBER: ${{ steps.get_release_info.outputs.version_number }}
_RELEASE_BRANCH: ${{ steps.get_release_branch.outputs.release_branch }}
_RELEASE_URL: ${{ steps.update_release_description.outputs.release_url }}
run: |
{
echo "# :fish_cake: Release ready at:"
echo "$_RELEASE_URL"
echo ""
} >> "$GITHUB_STEP_SUMMARY"
if [[ "$_VERSION_NAME" == "0.0.0" || "$_VERSION_NUMBER" == "0" ]]; then
{
echo "> [!CAUTION]"
echo "> Version name or number wasn't previously found and a default value was used. You'll need to manually update the release Title, Tag and Description, specifically, the \"Full Changelog\" link."
echo ""
} >> "$GITHUB_STEP_SUMMARY"
fi
{
echo ":clipboard: Confirm that the defined GitHub Release options are correct:"
echo " * :bookmark: New tag name: \`$_RELEASE_TAG\`"
echo " * :palm_tree: Target branch: \`$_RELEASE_BRANCH\`"
echo " * :ocean: Previous tag set in the description \"Full Changelog\" link: \`$_LAST_RELEASE_TAG\`"
echo " * :white_check_mark: Description has automated release notes and they match the commits in the release branch"
echo "> [!NOTE]"
echo "> Commits directly pushed to branches without a Pull Request won't appear in the automated release notes."
} >> "$GITHUB_STEP_SUMMARY"
azure_subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
azure_tenant_id: ${{ secrets.AZURE_TENANT_ID }}
azure_client_id: ${{ secrets.AZURE_CLIENT_ID }}
task: release-android

View File

@@ -50,11 +50,8 @@ import com.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar
import com.bitwarden.ui.platform.components.badge.BitwardenStatusBadge
import com.bitwarden.ui.platform.components.button.BitwardenFilledButton
import com.bitwarden.ui.platform.components.button.BitwardenOutlinedButton
import com.bitwarden.ui.platform.components.button.model.BitwardenButtonData
import com.bitwarden.ui.platform.components.card.BitwardenInfoCalloutCard
import com.bitwarden.ui.platform.components.content.BitwardenContentBlock
import com.bitwarden.ui.platform.components.content.BitwardenErrorContent
import com.bitwarden.ui.platform.components.content.BitwardenLoadingContent
import com.bitwarden.ui.platform.components.content.model.ContentBlockData
import com.bitwarden.ui.platform.components.dialog.BitwardenLoadingDialog
import com.bitwarden.ui.platform.components.dialog.BitwardenTwoButtonDialog
@@ -73,8 +70,6 @@ import com.bitwarden.ui.platform.theme.BitwardenTheme
import com.bitwarden.ui.util.asText
import com.x8bit.bitwarden.data.billing.repository.model.PremiumSubscriptionStatus
import com.x8bit.bitwarden.ui.platform.composition.LocalAuthTabLaunchers
import com.x8bit.bitwarden.ui.platform.feature.premium.plan.PlanState.ViewState.Error.Type.PRICING_UNAVAILABLE
import com.x8bit.bitwarden.ui.platform.feature.premium.plan.PlanState.ViewState.Error.Type.SUBSCRIPTION
import com.x8bit.bitwarden.ui.platform.feature.premium.plan.handlers.PlanHandlers
import com.x8bit.bitwarden.ui.platform.feature.premium.plan.util.badgeColors
import com.x8bit.bitwarden.ui.platform.feature.premium.plan.util.labelRes
@@ -149,47 +144,23 @@ fun PlanScreen(
},
) {
when (val viewState = state.viewState) {
is PlanState.ViewState.Content.Free.Cloud -> {
is PlanState.ViewState.Free.Cloud -> {
FreeCloudContent(
viewState = viewState,
handlers = handlers,
)
}
is PlanState.ViewState.Content.Free.SelfHosted -> {
is PlanState.ViewState.Free.SelfHosted -> {
FreeSelfHostedContent()
}
is PlanState.ViewState.Content.Premium -> {
is PlanState.ViewState.Premium -> {
PremiumContent(
viewState = viewState,
handlers = handlers,
)
}
is PlanState.ViewState.Error -> {
BitwardenErrorContent(
illustrationData = IconData.Local(iconRes = BitwardenDrawable.ill_file_error),
message = viewState.message(),
buttonData = BitwardenButtonData(
label = BitwardenString.try_again.asText(),
onClick = {
when (viewState.type) {
PRICING_UNAVAILABLE -> handlers.onRetryPricingClick()
SUBSCRIPTION -> handlers.onRetrySubscriptionClick()
}
},
),
modifier = Modifier.fillMaxSize(),
)
}
is PlanState.ViewState.Loading -> {
BitwardenLoadingContent(
text = viewState.message(),
modifier = Modifier.fillMaxSize(),
)
}
}
}
}
@@ -213,6 +184,18 @@ private fun PlanDialogs(
)
}
is PlanState.DialogState.GetPricingError -> {
BitwardenTwoButtonDialog(
title = dialogState.title(),
message = dialogState.message(),
confirmButtonText = stringResource(BitwardenString.try_again),
dismissButtonText = stringResource(BitwardenString.close),
onConfirmClick = handlers.onRetryPricingClick,
onDismissClick = handlers.onClosePricingErrorClick,
onDismissRequest = handlers.onClosePricingErrorClick,
)
}
is PlanState.DialogState.WaitingForPayment -> {
BitwardenTwoButtonDialog(
title = stringResource(id = BitwardenString.payment_not_received_yet),
@@ -267,6 +250,18 @@ private fun PlanDialogs(
)
}
is PlanState.DialogState.SubscriptionError -> {
BitwardenTwoButtonDialog(
title = dialogState.title(),
message = dialogState.message(),
confirmButtonText = stringResource(id = BitwardenString.try_again),
dismissButtonText = stringResource(id = BitwardenString.close),
onConfirmClick = handlers.onRetrySubscriptionClick,
onDismissClick = handlers.onBackClick,
onDismissRequest = handlers.onBackClick,
)
}
PlanState.DialogState.LoadingPortal -> {
BitwardenLoadingDialog(
text = stringResource(id = BitwardenString.loading_portal),
@@ -283,7 +278,7 @@ private fun PlanDialogs(
@Composable
private fun FreeCloudContent(
viewState: PlanState.ViewState.Content.Free.Cloud,
viewState: PlanState.ViewState.Free.Cloud,
handlers: PlanHandlers,
modifier: Modifier = Modifier,
) {
@@ -509,7 +504,7 @@ private fun PriceRow(
@Composable
private fun PremiumContent(
viewState: PlanState.ViewState.Content.Premium,
viewState: PlanState.ViewState.Premium,
handlers: PlanHandlers,
modifier: Modifier = Modifier,
) {
@@ -576,7 +571,7 @@ private fun PremiumContent(
@Composable
private fun SubscriptionCard(
viewState: PlanState.ViewState.Content.Premium,
viewState: PlanState.ViewState.Premium,
modifier: Modifier = Modifier,
) {
Column(
@@ -612,7 +607,7 @@ private fun SubscriptionCard(
@Composable
private fun SubscriptionLineItems(
viewState: PlanState.ViewState.Content.Premium,
viewState: PlanState.ViewState.Premium,
) {
val rowModifier = Modifier
.fillMaxWidth()
@@ -832,7 +827,7 @@ private fun PlanScreenFreeCloudAccount_preview() {
BitwardenTheme {
BitwardenScaffold {
FreeCloudContent(
viewState = PlanState.ViewState.Content.Free.Cloud(
viewState = PlanState.ViewState.Free.Cloud(
rate = "$1.67",
checkoutUrl = null,
isAwaitingPremiumStatus = false,
@@ -880,7 +875,7 @@ private fun PlanScreenPremiumAccount_preview() {
BitwardenTheme {
BitwardenScaffold {
PremiumContent(
viewState = PlanState.ViewState.Content.Premium(
viewState = PlanState.ViewState.Premium(
status = PremiumSubscriptionStatus.ACTIVE,
billingAmountText = BitwardenString.billing_rate_per_year.asText("$19.80"),
storageCostText = "$24.00",
@@ -922,7 +917,7 @@ private fun PlanScreenPremiumAccountZeroState_preview() {
BitwardenTheme {
BitwardenScaffold {
PremiumContent(
viewState = PlanState.ViewState.Content.Premium(
viewState = PlanState.ViewState.Premium(
status = PremiumSubscriptionStatus.ACTIVE,
billingAmountText = BitwardenString.billing_rate_per_year.asText("$19.80"),
storageCostText = null,

View File

@@ -36,10 +36,8 @@ import com.x8bit.bitwarden.ui.platform.feature.premium.plan.util.toDiscountMoney
import com.x8bit.bitwarden.ui.platform.feature.premium.plan.util.toPresentMoneyText
import com.x8bit.bitwarden.ui.platform.feature.premium.plan.util.toRequiredMoneyText
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
@@ -88,85 +86,80 @@ class PlanViewModel @Inject constructor(
premiumStateManager.subscriptionStatusStateFlow.value.isPremiumViewEligible()
val isSelfHosted = premiumStateManager.isSelfHosted
PlanState(
isSelfHosted = isSelfHosted,
showsPremiumView = showsPremiumView,
planMode = planMode,
viewState = when {
showsPremiumView -> {
// We are loading the premium data.
PlanState.ViewState.Loading(
message = BitwardenString.loading_subscription.asText(),
)
}
isSelfHosted -> {
// Nothing to load, we are good to go.
PlanState.ViewState.Content.Free.SelfHosted
}
else -> {
// We are loading the plan details.
PlanState.ViewState.Loading(
message = BitwardenString.loading.asText(),
)
}
showsPremiumView -> PlanState.ViewState.Premium()
isSelfHosted -> PlanState.ViewState.Free.SelfHosted
else -> PlanState.ViewState.Free.Cloud(
rate = PLACEHOLDER_TEXT,
checkoutUrl = null,
isAwaitingPremiumStatus = false,
isPremiumUpgradePending = premiumStateManager
.upgradeLifecycleStateFlow
.value is UpgradeLifecycleState.UpgradePending,
)
},
dialogState = null,
)
},
) {
private val currencyFormatter: NumberFormat = NumberFormat.getCurrencyInstance(Locale.US)
private val currencyFormatter: NumberFormat =
NumberFormat.getCurrencyInstance(Locale.US)
init {
stateFlow
.onEach { savedStateHandle[KEY_STATE] = it }
.launchIn(viewModelScope)
merge(
authRepository.userStateFlow.map { PlanAction.Internal.UserStateUpdateReceive(it) },
specialCircumstanceManager
.specialCircumstanceStateFlow
.map { PlanAction.Internal.SpecialCircumstanceReceive(it) },
premiumStateManager
.subscriptionStatusStateFlow
.map { PlanAction.Internal.SubscriptionStatusUpdateReceive(it) },
premiumStateManager
.upgradeLifecycleStateFlow
.map { PlanAction.Internal.UpgradeLifecycleStateReceive(it) },
)
.onEach {
// Wait until we are in the Content state so we can update everything appropriately
mutableStateFlow.first { it.viewState is PlanState.ViewState.Content }
}
authRepository
.userStateFlow
.map { PlanAction.Internal.UserStateUpdateReceive(it) }
.onEach(::sendAction)
.launchIn(viewModelScope)
when {
state.showsPremiumView -> {
// We are loading the premium data.
viewModelScope.launch {
sendAction(
PlanAction.Internal.SubscriptionResultReceive(
result = billingRepository.getSubscription(),
),
)
}
}
specialCircumstanceManager
.specialCircumstanceStateFlow
.map { PlanAction.Internal.SpecialCircumstanceReceive(it) }
.onEach(::sendAction)
.launchIn(viewModelScope)
state.isSelfHosted -> {
// Nothing to load, we are good to go.
}
premiumStateManager
.subscriptionStatusStateFlow
.map { PlanAction.Internal.SubscriptionStatusUpdateReceive(it) }
.onEach(::sendAction)
.launchIn(viewModelScope)
else -> {
// We are loading the plan details.
viewModelScope.launch {
sendAction(
PlanAction.Internal.PricingResultReceive(
result = billingRepository.getPremiumPlanPricing(),
),
)
}
premiumStateManager
.upgradeLifecycleStateFlow
.map { PlanAction.Internal.UpgradeLifecycleStateReceive(it) }
.onEach(::sendAction)
.launchIn(viewModelScope)
onFreeCloudContent {
viewModelScope.launch {
sendAction(
PlanAction.Internal.PricingResultReceive(
result = billingRepository.getPremiumPlanPricing(),
),
)
}
}
onPremiumContent {
mutableStateFlow.update {
it.copy(
dialogState = PlanState.DialogState.Loading(
message = BitwardenString.loading_subscription.asText(),
),
)
}
viewModelScope.launch {
sendAction(
PlanAction.Internal.SubscriptionResultReceive(
result = billingRepository.getSubscription(),
),
)
}
}
}
@@ -391,7 +384,7 @@ class PlanViewModel @Inject constructor(
private fun handleRetrySubscriptionClick() {
mutableStateFlow.update {
it.copy(
viewState = PlanState.ViewState.Loading(
dialogState = PlanState.DialogState.Loading(
message = BitwardenString.loading_subscription.asText(),
),
)
@@ -422,7 +415,15 @@ class PlanViewModel @Inject constructor(
SubscriptionResult.NotFound -> {
mutableStateFlow.update {
it.copy(
viewState = PlanState.ViewState.Loading(
viewState = PlanState.ViewState.Free.Cloud(
rate = PLACEHOLDER_TEXT,
checkoutUrl = null,
isAwaitingPremiumStatus = false,
isPremiumUpgradePending = premiumStateManager
.upgradeLifecycleStateFlow
.value is UpgradeLifecycleState.UpgradePending,
),
dialogState = PlanState.DialogState.Loading(
message = BitwardenString.loading.asText(),
),
)
@@ -439,9 +440,11 @@ class PlanViewModel @Inject constructor(
is SubscriptionResult.Error -> {
mutableStateFlow.update {
it.copy(
viewState = PlanState.ViewState.Error(
message = BitwardenString.trouble_loading_subscription.asText(),
type = PlanState.ViewState.Error.Type.SUBSCRIPTION,
dialogState = PlanState.DialogState.SubscriptionError(
title = BitwardenString.subscription_error.asText(),
message = BitwardenString
.trouble_loading_subscription
.asText(),
),
)
}
@@ -468,13 +471,15 @@ class PlanViewModel @Inject constructor(
private fun handleSubscriptionStatusUpdateReceive(
action: PlanAction.Internal.SubscriptionStatusUpdateReceive,
) {
val status = (action.state as? SubscriptionStatusState.Available)?.status ?: return
val status = (action.state as? SubscriptionStatusState.Available)?.status
?: return
if (!status.isPremiumViewEligible()) return
onFreeCloudContent { freeState ->
if (freeState.isAwaitingPremiumStatus) return@onFreeCloudContent
mutableStateFlow.update {
it.copy(
viewState = PlanState.ViewState.Loading(
viewState = PlanState.ViewState.Premium(),
dialogState = PlanState.DialogState.Loading(
message = BitwardenString.loading_subscription.asText(),
),
)
@@ -496,15 +501,10 @@ class PlanViewModel @Inject constructor(
private fun handleUserStateUpdateReceive(
action: PlanAction.Internal.UserStateUpdateReceive,
) {
val isPremium = action.userState?.activeAccount?.isPremium == true
mutableStateFlow.update {
it.copy(
showsPremiumView = isPremium ||
premiumStateManager.subscriptionStatusStateFlow.value.isPremiumViewEligible(),
)
}
onFreeCloudContent { freeState ->
if (!freeState.isAwaitingPremiumStatus) return@onFreeCloudContent
val isPremium = action.userState?.activeAccount?.isPremium == true
if (isPremium) {
onPremiumUpgradeSuccess()
}
@@ -528,7 +528,7 @@ class PlanViewModel @Inject constructor(
specialCircumstanceManager.specialCircumstance = null
mutableStateFlow.update {
it.copy(
viewState = PlanState.ViewState.Loading(
dialogState = PlanState.DialogState.Loading(
message = BitwardenString.loading_subscription.asText(),
),
)
@@ -622,7 +622,8 @@ class PlanViewModel @Inject constructor(
onFreeCloudContent {
mutableStateFlow.update {
it.copy(
viewState = PlanState.ViewState.Loading(
viewState = PlanState.ViewState.Premium(),
dialogState = PlanState.DialogState.Loading(
message = BitwardenString.loading_subscription.asText(),
),
)
@@ -646,16 +647,17 @@ class PlanViewModel @Inject constructor(
) {
when (val result = action.result) {
is PremiumPlanPricingResult.Success -> {
mutableStateFlow.update {
it.copy(
viewState = PlanState.ViewState.Content.Free.Cloud(
rate = currencyFormatter.format(result.annualPrice / MONTHS_PER_YEAR),
checkoutUrl = null,
isAwaitingPremiumStatus = false,
isPremiumUpgradePending = premiumStateManager
.upgradeLifecycleStateFlow
.value is UpgradeLifecycleState.UpgradePending,
),
val formattedRate = currencyFormatter
.format(result.annualPrice / MONTHS_PER_YEAR)
mutableStateFlow.update { currentState ->
val updatedViewState = when (val vs = currentState.viewState) {
is PlanState.ViewState.Free.Cloud -> vs.copy(rate = formattedRate)
is PlanState.ViewState.Free.SelfHosted,
is PlanState.ViewState.Premium,
-> vs
}
currentState.copy(
viewState = updatedViewState,
dialogState = null,
)
}
@@ -664,10 +666,10 @@ class PlanViewModel @Inject constructor(
is PremiumPlanPricingResult.Error -> {
mutableStateFlow.update {
it.copy(
viewState = PlanState.ViewState.Error(
dialogState = PlanState.DialogState.GetPricingError(
title = BitwardenString.pricing_unavailable.asText(),
message = result.errorMessage?.asText()
?: BitwardenString.pricing_unavailable.asText(),
type = PlanState.ViewState.Error.Type.PRICING_UNAVAILABLE,
?: BitwardenString.generic_error_message.asText(),
),
)
}
@@ -678,7 +680,7 @@ class PlanViewModel @Inject constructor(
private fun handleRetryPricingClick() {
mutableStateFlow.update {
it.copy(
viewState = PlanState.ViewState.Loading(
dialogState = PlanState.DialogState.Loading(
message = BitwardenString.loading.asText(),
),
)
@@ -693,25 +695,25 @@ class PlanViewModel @Inject constructor(
}
private inline fun onFreeCloudContent(
block: (PlanState.ViewState.Content.Free.Cloud) -> Unit,
block: (PlanState.ViewState.Free.Cloud) -> Unit,
) {
(state.viewState as? PlanState.ViewState.Content.Free.Cloud)?.let(block)
(state.viewState as? PlanState.ViewState.Free.Cloud)?.let(block)
}
private inline fun onPremiumContent(
block: (PlanState.ViewState.Content.Premium) -> Unit,
block: (PlanState.ViewState.Premium) -> Unit,
) {
(state.viewState as? PlanState.ViewState.Content.Premium)?.let(block)
(state.viewState as? PlanState.ViewState.Premium)?.let(block)
}
private fun SubscriptionInfo.toPremiumViewState(): PlanState.ViewState.Content.Premium {
private fun SubscriptionInfo.toPremiumViewState(): PlanState.ViewState.Premium {
val formattedTotal = currencyFormatter.format(nextChargeTotal)
val formattedDate = nextCharge?.toLocalizedDate()
val formattedCancelAt = cancelAt?.toLocalizedDate()
val formattedCanceled = canceledDate?.toLocalizedDate()
val formattedSuspension = suspensionDate?.toLocalizedDate()
return PlanState.ViewState.Content.Premium(
return PlanState.ViewState.Premium(
status = status,
billingAmountText = seatsCost.toBillingAmountText(cadence, currencyFormatter),
storageCostText = storageCost.toPresentMoneyText(currencyFormatter),
@@ -758,8 +760,6 @@ data class PlanState(
val planMode: PlanMode,
val viewState: ViewState,
val dialogState: DialogState?,
val showsPremiumView: Boolean,
val isSelfHosted: Boolean,
) : Parcelable {
/**
@@ -787,7 +787,10 @@ data class PlanState(
*/
@get:StringRes
val title: Int
get() = if (showsPremiumView) BitwardenString.plan else BitwardenString.upgrade_to_premium
get() = when (viewState) {
is ViewState.Free -> BitwardenString.upgrade_to_premium
is ViewState.Premium -> BitwardenString.plan
}
/**
* Models the content state of the plan screen.
@@ -795,93 +798,65 @@ data class PlanState(
sealed class ViewState : Parcelable {
/**
* Displays a loading state.
* Free user view — shows the upgrade flow for cloud accounts or a
* "manage on web vault" info card for self-hosted accounts.
*/
@Parcelize
data class Loading(val message: Text) : ViewState()
/**
* Displays an error state.
*/
@Parcelize
data class Error(
val message: Text,
val type: Type,
) : ViewState() {
/**
* The specific type of error this represents.
*/
enum class Type {
PRICING_UNAVAILABLE,
SUBSCRIPTION,
}
}
/**
* Displays a plan content.
*/
sealed class Content : ViewState() {
/**
* Free user view — shows the upgrade flow for cloud accounts or a
* "manage on web vault" info card for self-hosted accounts.
*/
sealed class Free : Content() {
/**
* Free user on a cloud-hosted environment — shows upgrade pricing
* and feature list.
*/
@Parcelize
data class Cloud(
val rate: String,
val checkoutUrl: String?,
val isAwaitingPremiumStatus: Boolean,
val isPremiumUpgradePending: Boolean,
) : Free()
/**
* Free user on a self-hosted environment — Stripe checkout is
* unavailable, so the screen redirects the user to manage their
* subscription on the web vault.
*/
@Parcelize
data object SelfHosted : Free()
}
sealed class Free : ViewState() {
/**
* Premium user view — shows subscription details and management options.
*
* Line-item text fields follow two visibility contracts that mirror the
* canonical Web subscription card:
*
* - **Required** ([billingAmountText], [estimatedTaxText], [totalText]):
* the row is always rendered. A zero amount is formatted as `$0.00`
* rather than hidden. Defaults are sensible empty values used only
* during the initial load — the `DialogState.Loading` overlay covers
* the screen during the fetch, so these defaults are never surfaced
* to the user.
* - **Optional** ([storageCostText], [discountAmountText]): a `null`
* value signals the screen to omit the row entirely (along with its
* leading divider). When non-null, the value is fully formatted by
* the view model — the screen renders it verbatim.
* Free user on a cloud-hosted environment — shows upgrade pricing
* and feature list.
*/
@Parcelize
data class Premium(
val status: PremiumSubscriptionStatus? = null,
val billingAmountText: Text = "".asText(),
val storageCostText: String? = null,
val discountAmountText: String? = null,
val estimatedTaxText: String = "$0.00",
val totalText: Text = "".asText(),
val nextChargeTotalText: String? = null,
val nextChargeDateText: String? = null,
val cancelAtDateText: String? = null,
val canceledDateText: String? = null,
val suspensionDateText: String? = null,
val gracePeriodDays: Int? = null,
val showCancelButton: Boolean = false,
) : Content()
data class Cloud(
val rate: String,
val checkoutUrl: String?,
val isAwaitingPremiumStatus: Boolean,
val isPremiumUpgradePending: Boolean,
) : Free()
/**
* Free user on a self-hosted environment — Stripe checkout is
* unavailable, so the screen redirects the user to manage their
* subscription on the web vault.
*/
@Parcelize
data object SelfHosted : Free()
}
/**
* Premium user view — shows subscription details and management options.
*
* Line-item text fields follow two visibility contracts that mirror the
* canonical Web subscription card:
*
* - **Required** ([billingAmountText], [estimatedTaxText], [totalText]):
* the row is always rendered. A zero amount is formatted as `$0.00`
* rather than hidden. Defaults are sensible empty values used only
* during the initial load — the `DialogState.Loading` overlay covers
* the screen during the fetch, so these defaults are never surfaced
* to the user.
* - **Optional** ([storageCostText], [discountAmountText]): a `null`
* value signals the screen to omit the row entirely (along with its
* leading divider). When non-null, the value is fully formatted by
* the view model — the screen renders it verbatim.
*/
@Parcelize
data class Premium(
val status: PremiumSubscriptionStatus? = null,
val billingAmountText: Text = "".asText(),
val storageCostText: String? = null,
val discountAmountText: String? = null,
val estimatedTaxText: String = "$0.00",
val totalText: Text = "".asText(),
val nextChargeTotalText: String? = null,
val nextChargeDateText: String? = null,
val cancelAtDateText: String? = null,
val canceledDateText: String? = null,
val suspensionDateText: String? = null,
val gracePeriodDays: Int? = null,
val showCancelButton: Boolean = false,
) : ViewState()
}
/**
@@ -903,6 +878,15 @@ data class PlanState(
@Parcelize
data object CheckoutError : DialogState()
/**
* Error dialog shown when pricing information cannot be retrieved.
*/
@Parcelize
data class GetPricingError(
val title: Text,
val message: Text,
) : DialogState()
/**
* Waiting dialog shown when the user returns from checkout without
* completing payment.
@@ -936,6 +920,15 @@ data class PlanState(
*/
@Parcelize
data object PortalError : DialogState()
/**
* Error dialog shown when subscription details cannot be loaded.
*/
@Parcelize
data class SubscriptionError(
val title: Text,
val message: Text,
) : DialogState()
}
}

View File

@@ -388,95 +388,74 @@ class PlanScreenTest : BitwardenComposeTest() {
// endregion PendingUpgrade dialog tests
// region Loading and Error content
// region GetPricingError dialog tests
@Test
fun `loading content should render message when viewState is Loading`() {
mutableStateFlow.update {
it.copy(
viewState = PlanState.ViewState.Loading(
message = BitwardenString.loading_subscription.asText(),
),
)
}
composeTestRule
.onNodeWithText("Loading subscription…")
.assertIsDisplayed()
}
fun `get pricing error dialog should render when dialogState is GetPricingError`() {
val title = "An error has occurred".asText()
val message = "Unable to retrieve pricing.".asText()
@Test
fun `error content should render message and try again button when viewState is Error`() {
composeTestRule
.onNodeWithText("Pricing unavailable")
.onAllNodesWithText("An error has occurred")
.filterToOne(hasAnyAncestor(isDialog()))
.assertDoesNotExist()
mutableStateFlow.update {
it.copy(
viewState = PlanState.ViewState.Error(
message = BitwardenString.pricing_unavailable.asText(),
type = PlanState.ViewState.Error.Type.PRICING_UNAVAILABLE,
dialogState = PlanState.DialogState.GetPricingError(
title = title,
message = message,
),
)
}
composeTestRule
.onNodeWithText("Pricing unavailable")
.assertIsDisplayed()
.onAllNodesWithText("An error has occurred")
.filterToOne(hasAnyAncestor(isDialog()))
.assertExists()
composeTestRule
.onNodeWithText("Try again")
.assertIsDisplayed()
.onAllNodesWithText("Unable to retrieve pricing.")
.filterToOne(hasAnyAncestor(isDialog()))
.assertExists()
}
@Test
fun `error content try again click for PRICING_UNAVAILABLE should send RetryPricingClick`() {
fun `get pricing error dialog try again click should send RetryPricingClick action`() {
mutableStateFlow.update {
it.copy(
viewState = PlanState.ViewState.Error(
message = BitwardenString.pricing_unavailable.asText(),
type = PlanState.ViewState.Error.Type.PRICING_UNAVAILABLE,
dialogState = PlanState.DialogState.GetPricingError(
title = "An error has occurred".asText(),
message = "Unable to retrieve pricing.".asText(),
),
)
}
composeTestRule
.onNodeWithText("Try again")
.onAllNodesWithText("Try again")
.filterToOne(hasAnyAncestor(isDialog()))
.performClick()
verify { viewModel.trySendAction(PlanAction.RetryPricingClick) }
}
@Test
fun `error content should render subscription message when type is SUBSCRIPTION`() {
fun `get pricing error dialog close click should send ClosePricingErrorClick action`() {
mutableStateFlow.update {
it.copy(
viewState = PlanState.ViewState.Error(
message = BitwardenString.trouble_loading_subscription.asText(),
type = PlanState.ViewState.Error.Type.SUBSCRIPTION,
dialogState = PlanState.DialogState.GetPricingError(
title = "An error has occurred".asText(),
message = "Unable to retrieve pricing.".asText(),
),
)
}
composeTestRule
.onNodeWithText(
"We couldnt load your subscription details. Please try again.",
)
.assertIsDisplayed()
}
@Test
fun `error content try again click for SUBSCRIPTION should send RetrySubscriptionClick`() {
mutableStateFlow.update {
it.copy(
viewState = PlanState.ViewState.Error(
message = BitwardenString.trouble_loading_subscription.asText(),
type = PlanState.ViewState.Error.Type.SUBSCRIPTION,
),
)
}
composeTestRule
.onNodeWithText("Try again")
.onAllNodesWithText("Close")
.filterToOne(hasAnyAncestor(isDialog()))
.performClick()
verify { viewModel.trySendAction(PlanAction.RetrySubscriptionClick) }
verify {
viewModel.trySendAction(PlanAction.ClosePricingErrorClick)
}
}
// endregion Loading and Error content
// endregion GetPricingError dialog tests
// region Premium content rendering
@@ -1088,6 +1067,80 @@ class PlanScreenTest : BitwardenComposeTest() {
// region Premium-flow dialogs
@Test
fun `subscription error dialog should render when dialogState is SubscriptionError`() {
val title = "An error has occurred".asText()
val message = "Unable to load subscription.".asText()
composeTestRule
.onAllNodesWithText("An error has occurred")
.filterToOne(hasAnyAncestor(isDialog()))
.assertDoesNotExist()
mutableStateFlow.update {
it.copy(
viewState = PlanState.ViewState.Premium(),
dialogState = PlanState.DialogState.SubscriptionError(
title = title,
message = message,
),
)
}
composeTestRule
.onAllNodesWithText("An error has occurred")
.filterToOne(hasAnyAncestor(isDialog()))
.assertExists()
composeTestRule
.onAllNodesWithText("Unable to load subscription.")
.filterToOne(hasAnyAncestor(isDialog()))
.assertExists()
composeTestRule
.onAllNodesWithText("Try again")
.filterToOne(hasAnyAncestor(isDialog()))
.assertExists()
composeTestRule
.onAllNodesWithText("Close")
.filterToOne(hasAnyAncestor(isDialog()))
.assertExists()
}
@Test
fun `subscription error dialog try again click should send RetrySubscriptionClick action`() {
mutableStateFlow.update {
it.copy(
viewState = PlanState.ViewState.Premium(),
dialogState = PlanState.DialogState.SubscriptionError(
title = "An error has occurred".asText(),
message = "Unable to load subscription.".asText(),
),
)
}
composeTestRule
.onAllNodesWithText("Try again")
.filterToOne(hasAnyAncestor(isDialog()))
.performClick()
verify { viewModel.trySendAction(PlanAction.RetrySubscriptionClick) }
}
@Test
fun `subscription error dialog close click should send BackClick action`() {
mutableStateFlow.update {
it.copy(
viewState = PlanState.ViewState.Premium(),
dialogState = PlanState.DialogState.SubscriptionError(
title = "An error has occurred".asText(),
message = "Unable to load subscription.".asText(),
),
)
}
composeTestRule
.onAllNodesWithText("Close")
.filterToOne(hasAnyAncestor(isDialog()))
.performClick()
verify { viewModel.trySendAction(PlanAction.BackClick) }
}
@Test
fun `loading portal dialog should render when dialogState is LoadingPortal`() {
composeTestRule
@@ -1241,7 +1294,7 @@ class PlanScreenTest : BitwardenComposeTest() {
@Test
fun `manage subscription info callout should render when self-hosted free`() {
mutableStateFlow.update {
it.copy(viewState = PlanState.ViewState.Content.Free.SelfHosted)
it.copy(viewState = PlanState.ViewState.Free.SelfHosted)
}
composeTestRule
.onNodeWithText(
@@ -1257,7 +1310,7 @@ class PlanScreenTest : BitwardenComposeTest() {
@Test
fun `premium features header should render when self-hosted free`() {
mutableStateFlow.update {
it.copy(viewState = PlanState.ViewState.Content.Free.SelfHosted)
it.copy(viewState = PlanState.ViewState.Free.SelfHosted)
}
composeTestRule
.onNodeWithText("Unlock more advanced features with a Premium plan.")
@@ -1267,7 +1320,7 @@ class PlanScreenTest : BitwardenComposeTest() {
@Test
fun `premium feature list items should render when self-hosted free`() {
mutableStateFlow.update {
it.copy(viewState = PlanState.ViewState.Content.Free.SelfHosted)
it.copy(viewState = PlanState.ViewState.Free.SelfHosted)
}
composeTestRule
.onNodeWithText("Built-in authenticator")
@@ -1318,18 +1371,16 @@ class PlanScreenTest : BitwardenComposeTest() {
private val DEFAULT_FREE_STATE = PlanState(
planMode = PlanMode.Modal,
viewState = PlanState.ViewState.Content.Free.Cloud(
viewState = PlanState.ViewState.Free.Cloud(
rate = "$1.65",
checkoutUrl = null,
isAwaitingPremiumStatus = false,
isPremiumUpgradePending = false,
),
dialogState = null,
showsPremiumView = false,
isSelfHosted = false,
)
private val DEFAULT_PREMIUM_VIEW_STATE = PlanState.ViewState.Content.Premium(
private val DEFAULT_PREMIUM_VIEW_STATE = PlanState.ViewState.Premium(
status = PremiumSubscriptionStatus.ACTIVE,
billingAmountText = BitwardenString.billing_rate_per_year.asText("$19.80"),
storageCostText = "$24.00",

View File

@@ -144,7 +144,7 @@ class PlanViewModelTest : BaseViewModelTest() {
assertEquals(
DEFAULT_FREE_STATE.copy(
viewState = PlanState.ViewState.Content.Free.Cloud(
viewState = PlanState.ViewState.Free.Cloud(
rate = "$1.67",
checkoutUrl = null,
isAwaitingPremiumStatus = true,
@@ -170,9 +170,7 @@ class PlanViewModelTest : BaseViewModelTest() {
),
)
// The premium subscription must resolve so the screen reaches a Content state and
// the special-circumstance flow is processed.
val viewModel = createViewModel(subscriptionResult = SUBSCRIPTION_SUCCESS_ACTIVE)
val viewModel = createViewModel()
viewModel.eventFlow.test {
mutableSpecialCircumstanceStateFlow.value =
@@ -227,7 +225,7 @@ class PlanViewModelTest : BaseViewModelTest() {
)
assertEquals(
DEFAULT_FREE_STATE.copy(
viewState = PlanState.ViewState.Content.Free.Cloud(
viewState = PlanState.ViewState.Free.Cloud(
rate = "$1.67",
checkoutUrl = checkoutUrl,
isAwaitingPremiumStatus = false,
@@ -318,7 +316,7 @@ class PlanViewModelTest : BaseViewModelTest() {
)
assertEquals(
DEFAULT_FREE_STATE.copy(
viewState = PlanState.ViewState.Content.Free.Cloud(
viewState = PlanState.ViewState.Free.Cloud(
rate = "$1.67",
checkoutUrl = checkoutUrl,
isAwaitingPremiumStatus = false,
@@ -391,7 +389,7 @@ class PlanViewModelTest : BaseViewModelTest() {
fun `GoBackClick should emit LaunchBrowser with checkout URL when URL is available`() =
runTest {
val checkoutUrl = "https://checkout.stripe.com/session123"
val freeState = PlanState.ViewState.Content.Free.Cloud(
val freeState = PlanState.ViewState.Free.Cloud(
rate = "$1.67",
checkoutUrl = checkoutUrl,
isAwaitingPremiumStatus = false,
@@ -440,7 +438,7 @@ class PlanViewModelTest : BaseViewModelTest() {
runTest {
val viewModel = createViewModel(
initialState = DEFAULT_FREE_STATE.copy(
viewState = PlanState.ViewState.Content.Free.Cloud(
viewState = PlanState.ViewState.Free.Cloud(
rate = "$1.67",
checkoutUrl = null,
isAwaitingPremiumStatus = true,
@@ -483,7 +481,7 @@ class PlanViewModelTest : BaseViewModelTest() {
assertEquals(
DEFAULT_FREE_STATE.copy(
viewState = PlanState.ViewState.Content.Free.Cloud(
viewState = PlanState.ViewState.Free.Cloud(
rate = "$1.67",
checkoutUrl = null,
isAwaitingPremiumStatus = true,
@@ -501,14 +499,13 @@ class PlanViewModelTest : BaseViewModelTest() {
),
)
// State transitions to a subscription Loading view state.
// State transitions to Premium with subscription Loading.
assertEquals(
DEFAULT_FREE_STATE.copy(
viewState = PlanState.ViewState.Loading(
viewState = PlanState.ViewState.Premium(),
dialogState = PlanState.DialogState.Loading(
message = BitwardenString.loading_subscription.asText(),
),
dialogState = PlanState.DialogState.WaitingForPayment,
showsPremiumView = true,
),
stateFlow.awaitItem(),
)
@@ -540,7 +537,7 @@ class PlanViewModelTest : BaseViewModelTest() {
// Sync completes without premium — PendingUpgrade shown.
assertEquals(
DEFAULT_FREE_STATE.copy(
viewState = PlanState.ViewState.Content.Free.Cloud(
viewState = PlanState.ViewState.Free.Cloud(
rate = "$1.67",
checkoutUrl = null,
isAwaitingPremiumStatus = true,
@@ -565,7 +562,7 @@ class PlanViewModelTest : BaseViewModelTest() {
fun `ContinueClick dismisses the PendingUpgrade dialog and navigates back`() = runTest {
val viewModel = createViewModel(
initialState = DEFAULT_FREE_STATE.copy(
viewState = PlanState.ViewState.Content.Free.Cloud(
viewState = PlanState.ViewState.Free.Cloud(
rate = "$1.67",
checkoutUrl = null,
isAwaitingPremiumStatus = true,
@@ -593,7 +590,7 @@ class PlanViewModelTest : BaseViewModelTest() {
assertEquals(
DEFAULT_FREE_STATE.copy(
viewState = PlanState.ViewState.Content.Free.Cloud(
viewState = PlanState.ViewState.Free.Cloud(
rate = "$1.67",
checkoutUrl = null,
isAwaitingPremiumStatus = false,
@@ -614,7 +611,7 @@ class PlanViewModelTest : BaseViewModelTest() {
runTest {
val viewModel = createViewModel(
initialState = DEFAULT_FREE_STATE.copy(
viewState = PlanState.ViewState.Content.Free.Cloud(
viewState = PlanState.ViewState.Free.Cloud(
rate = "$1.67",
checkoutUrl = null,
isAwaitingPremiumStatus = true,
@@ -690,10 +687,8 @@ class PlanViewModelTest : BaseViewModelTest() {
assertEquals(
PlanState(
planMode = PlanMode.Modal,
viewState = PlanState.ViewState.Content.Free.SelfHosted,
viewState = PlanState.ViewState.Free.SelfHosted,
dialogState = null,
showsPremiumView = false,
isSelfHosted = true,
),
awaitItem(),
)
@@ -725,7 +720,19 @@ class PlanViewModelTest : BaseViewModelTest() {
val viewModel = createViewModel()
viewModel.stateFlow.test {
assertEquals(DEFAULT_FREE_STATE, awaitItem())
assertEquals(
PlanState(
planMode = PlanMode.Modal,
viewState = PlanState.ViewState.Free.Cloud(
rate = "$1.67",
checkoutUrl = null,
isAwaitingPremiumStatus = false,
isPremiumUpgradePending = false,
),
dialogState = null,
),
awaitItem(),
)
}
coVerify(exactly = 1) {
mockBillingRepository.getPremiumPlanPricing()
@@ -737,7 +744,7 @@ class PlanViewModelTest : BaseViewModelTest() {
// region Pricing fetch
@Test
fun `initial state before pricing fetch resolves should show Loading view state`() =
fun `initial state before pricing fetch resolves should show placeholder rate`() =
runTest {
val viewModel = createViewModel(pricingResult = null)
@@ -745,12 +752,13 @@ class PlanViewModelTest : BaseViewModelTest() {
assertEquals(
PlanState(
planMode = PlanMode.Modal,
viewState = PlanState.ViewState.Loading(
message = BitwardenString.loading.asText(),
viewState = PlanState.ViewState.Free.Cloud(
rate = "--",
checkoutUrl = null,
isAwaitingPremiumStatus = false,
isPremiumUpgradePending = false,
),
dialogState = null,
showsPremiumView = false,
isSelfHosted = false,
),
awaitItem(),
)
@@ -758,7 +766,7 @@ class PlanViewModelTest : BaseViewModelTest() {
}
@Test
fun `pricing fetch failure should show Error view state`() =
fun `pricing fetch failure should show GetPricingError dialog`() =
runTest {
val viewModel = createViewModel(
pricingResult = PremiumPlanPricingResult.Error(
@@ -770,13 +778,16 @@ class PlanViewModelTest : BaseViewModelTest() {
assertEquals(
PlanState(
planMode = PlanMode.Modal,
viewState = PlanState.ViewState.Error(
message = BitwardenString.pricing_unavailable.asText(),
type = PlanState.ViewState.Error.Type.PRICING_UNAVAILABLE,
viewState = PlanState.ViewState.Free.Cloud(
rate = "--",
checkoutUrl = null,
isAwaitingPremiumStatus = false,
isPremiumUpgradePending = false,
),
dialogState = PlanState.DialogState.GetPricingError(
title = BitwardenString.pricing_unavailable.asText(),
message = BitwardenString.generic_error_message.asText(),
),
dialogState = null,
showsPremiumView = false,
isSelfHosted = false,
),
awaitItem(),
)
@@ -796,13 +807,16 @@ class PlanViewModelTest : BaseViewModelTest() {
assertEquals(
PlanState(
planMode = PlanMode.Modal,
viewState = PlanState.ViewState.Error(
message = BitwardenString.pricing_unavailable.asText(),
type = PlanState.ViewState.Error.Type.PRICING_UNAVAILABLE,
viewState = PlanState.ViewState.Free.Cloud(
rate = "--",
checkoutUrl = null,
isAwaitingPremiumStatus = false,
isPremiumUpgradePending = false,
),
dialogState = PlanState.DialogState.GetPricingError(
title = BitwardenString.pricing_unavailable.asText(),
message = BitwardenString.generic_error_message.asText(),
),
dialogState = null,
showsPremiumView = false,
isSelfHosted = false,
),
awaitItem(),
)
@@ -817,12 +831,15 @@ class PlanViewModelTest : BaseViewModelTest() {
assertEquals(
PlanState(
planMode = PlanMode.Modal,
viewState = PlanState.ViewState.Loading(
viewState = PlanState.ViewState.Free.Cloud(
rate = "--",
checkoutUrl = null,
isAwaitingPremiumStatus = false,
isPremiumUpgradePending = false,
),
dialogState = PlanState.DialogState.Loading(
message = BitwardenString.loading.asText(),
),
dialogState = null,
showsPremiumView = false,
isSelfHosted = false,
),
awaitItem(),
)
@@ -834,7 +851,7 @@ class PlanViewModelTest : BaseViewModelTest() {
}
@Test
fun `ClosePricingErrorClick should emit NavigateBack`() =
fun `ClosePricingErrorClick should clear dialog and emit NavigateBack`() =
runTest {
val viewModel = createViewModel(
pricingResult = PremiumPlanPricingResult.Error(
@@ -842,12 +859,42 @@ class PlanViewModelTest : BaseViewModelTest() {
),
)
viewModel.eventFlow.test {
viewModel.stateEventFlow(backgroundScope) { stateFlow, eventFlow ->
assertEquals(
PlanState(
planMode = PlanMode.Modal,
viewState = PlanState.ViewState.Free.Cloud(
rate = "--",
checkoutUrl = null,
isAwaitingPremiumStatus = false,
isPremiumUpgradePending = false,
),
dialogState = PlanState.DialogState.GetPricingError(
title = BitwardenString.pricing_unavailable.asText(),
message = BitwardenString.generic_error_message.asText(),
),
),
stateFlow.awaitItem(),
)
viewModel.trySendAction(PlanAction.ClosePricingErrorClick)
assertEquals(
PlanState(
planMode = PlanMode.Modal,
viewState = PlanState.ViewState.Free.Cloud(
rate = "--",
checkoutUrl = null,
isAwaitingPremiumStatus = false,
isPremiumUpgradePending = false,
),
dialogState = null,
),
stateFlow.awaitItem(),
)
assertEquals(
PlanEvent.NavigateBack,
awaitItem(),
eventFlow.awaitItem(),
)
}
}
@@ -879,7 +926,7 @@ class PlanViewModelTest : BaseViewModelTest() {
// region Premium user path
@Test
fun `initial state should be Loading ViewState for premium user`() =
fun `initial state should be Premium ViewState with loading dialog for premium user`() =
runTest {
markUserPremium()
@@ -958,12 +1005,15 @@ class PlanViewModelTest : BaseViewModelTest() {
)
val loadingState = awaitItem()
assertEquals(
PlanState.ViewState.Loading(
message = BitwardenString.loading_subscription.asText(),
),
PlanState.ViewState.Premium(),
loadingState.viewState,
)
assertEquals(null, loadingState.dialogState)
assertEquals(
PlanState.DialogState.Loading(
message = BitwardenString.loading_subscription.asText(),
),
loadingState.dialogState,
)
val loadedState = awaitItem()
assertEquals(
DEFAULT_PREMIUM_ACTIVE_VIEW_STATE.copy(
@@ -986,15 +1036,13 @@ class PlanViewModelTest : BaseViewModelTest() {
)
viewModel.stateFlow.test {
// The account is premium, so showsPremiumView stays true even though the
// missing subscription drops the screen back to the Free Cloud upgrade view.
assertEquals(DEFAULT_FREE_STATE.copy(showsPremiumView = true), awaitItem())
assertEquals(DEFAULT_FREE_STATE, awaitItem())
}
}
@Suppress("MaxLineLength")
@Test
fun `SubscriptionResultReceive NotFound keeps Loading view state up while pricing fetch is pending`() =
fun `SubscriptionResultReceive NotFound keeps Loading dialog up while pricing fetch is pending`() =
runTest {
markUserPremium()
@@ -1005,8 +1053,14 @@ class PlanViewModelTest : BaseViewModelTest() {
viewModel.stateFlow.test {
assertEquals(
DEFAULT_PREMIUM_LOADING_STATE.copy(
viewState = PlanState.ViewState.Loading(
DEFAULT_FREE_STATE.copy(
viewState = PlanState.ViewState.Free.Cloud(
rate = "--",
checkoutUrl = null,
isAwaitingPremiumStatus = false,
isPremiumUpgradePending = false,
),
dialogState = PlanState.DialogState.Loading(
message = BitwardenString.loading.asText(),
),
),
@@ -1393,7 +1447,7 @@ class PlanViewModelTest : BaseViewModelTest() {
}
@Test
fun `SubscriptionResultReceive Error should show Error view state`() = runTest {
fun `SubscriptionResultReceive Error should show SubscriptionError dialog`() = runTest {
markUserPremium()
val viewModel = createViewModel(
@@ -1403,9 +1457,11 @@ class PlanViewModelTest : BaseViewModelTest() {
viewModel.stateFlow.test {
assertEquals(
DEFAULT_PREMIUM_LOADING_STATE.copy(
viewState = PlanState.ViewState.Error(
message = BitwardenString.trouble_loading_subscription.asText(),
type = PlanState.ViewState.Error.Type.SUBSCRIPTION,
dialogState = PlanState.DialogState.SubscriptionError(
title = BitwardenString.subscription_error.asText(),
message = BitwardenString
.trouble_loading_subscription
.asText(),
),
),
awaitItem(),
@@ -1429,7 +1485,7 @@ class PlanViewModelTest : BaseViewModelTest() {
assertEquals(
DEFAULT_PREMIUM_LOADED_STATE.copy(
viewState = PlanState.ViewState.Loading(
dialogState = PlanState.DialogState.Loading(
message = BitwardenString.loading_subscription.asText(),
),
),
@@ -1610,7 +1666,7 @@ class PlanViewModelTest : BaseViewModelTest() {
assertEquals(
DEFAULT_PREMIUM_LOADED_STATE.copy(
viewState = PlanState.ViewState.Loading(
dialogState = PlanState.DialogState.Loading(
message = BitwardenString.loading_subscription.asText(),
),
),
@@ -1648,7 +1704,7 @@ class PlanViewModelTest : BaseViewModelTest() {
assertEquals(
DEFAULT_PREMIUM_LOADED_STATE.copy(
viewState = PlanState.ViewState.Loading(
dialogState = PlanState.DialogState.Loading(
message = BitwardenString.loading_subscription.asText(),
),
),
@@ -1806,15 +1862,13 @@ private val DEFAULT_USER_STATE = UserState(
private val DEFAULT_FREE_STATE = PlanState(
planMode = PlanMode.Modal,
viewState = PlanState.ViewState.Content.Free.Cloud(
viewState = PlanState.ViewState.Free.Cloud(
rate = "$1.67",
checkoutUrl = null,
isAwaitingPremiumStatus = false,
isPremiumUpgradePending = false,
),
dialogState = null,
showsPremiumView = false,
isSelfHosted = false,
)
private const val ANNUAL_PRICE = 19.99
@@ -1846,7 +1900,7 @@ private val DEFAULT_PRICING_SUCCESS = PremiumPlanPricingResult.Success(
annualPrice = ANNUAL_PRICE,
)
private val DEFAULT_PREMIUM_ACTIVE_VIEW_STATE = PlanState.ViewState.Content.Premium(
private val DEFAULT_PREMIUM_ACTIVE_VIEW_STATE = PlanState.ViewState.Premium(
status = PremiumSubscriptionStatus.ACTIVE,
billingAmountText = BitwardenString.billing_rate_per_year.asText("$19.80"),
storageCostText = "$24.00",
@@ -1862,16 +1916,12 @@ private val DEFAULT_PREMIUM_LOADED_STATE = PlanState(
planMode = PlanMode.Modal,
viewState = DEFAULT_PREMIUM_ACTIVE_VIEW_STATE,
dialogState = null,
showsPremiumView = true,
isSelfHosted = false,
)
private val DEFAULT_PREMIUM_LOADING_STATE = PlanState(
planMode = PlanMode.Modal,
viewState = PlanState.ViewState.Loading(
viewState = PlanState.ViewState.Premium(),
dialogState = PlanState.DialogState.Loading(
message = BitwardenString.loading_subscription.asText(),
),
dialogState = null,
showsPremiumView = true,
isSelfHosted = false,
)

View File

@@ -1324,6 +1324,7 @@ Do you want to switch to this account?</string>
<string name="loading_portal">Loading portal…</string>
<string name="portal_error">Something went wrong</string>
<string name="trouble_loading_portal">We had trouble loading the management portal, so try again.</string>
<string name="subscription_error">Subscription error</string>
<string name="trouble_loading_subscription">We couldnt load your subscription details. Please try again.</string>
<string name="view_bank_account">View bank account</string>
<string name="view_license">View license</string>