Compare commits

..

9 Commits

Author SHA1 Message Date
bw-ghapp[bot]
35a1107258 SDK Update - com.bitwarden:sdk-android 3.0.0-7459-378df4c3 2026-06-19 17:45:15 +00:00
bw-ghapp[bot]
a33b7c1ef7 SDK Update - com.bitwarden:sdk-android 3.0.0-7436-4b41c1c0 2026-06-17 16:13:27 +00:00
bw-ghapp[bot]
af23f0ef1e SDK Update - com.bitwarden:sdk-android 3.0.0-7435-fdf056a2 2026-06-17 15:03:41 +00:00
bw-ghapp[bot]
a2ccb09502 SDK Update - com.bitwarden:sdk-android 3.0.0-7433-55170fd2 2026-06-17 09:55:35 +00:00
bw-ghapp[bot]
6f1b848fe8 SDK Update - com.bitwarden:sdk-android 3.0.0-7431-dfc9bd71 2026-06-16 21:55:14 +00:00
bw-ghapp[bot]
55934ed32b SDK Update - com.bitwarden:sdk-android 3.0.0-7429-61b6ed04 2026-06-16 16:39:03 +00:00
bw-ghapp[bot]
5943829f6c SDK Update - com.bitwarden:sdk-android 3.0.0-7423-a5a044d9 2026-06-16 14:03:00 +00:00
bw-ghapp[bot]
d9e5a161be SDK Update - com.bitwarden:sdk-android 3.0.0-7419-ea01f9bf 2026-06-15 21:48:02 +00:00
bw-ghapp[bot]
67b57044ce SDK Update - com.bitwarden:sdk-android 3.0.0-7413-5991472e 2026-06-15 19:54:10 +00:00
42 changed files with 914 additions and 1091 deletions

View File

@@ -16,17 +16,279 @@ env:
ARTIFACTS_PATH: artifacts
jobs:
jobs:
deploy:
name: Trigger publish via deploy repo
create-release:
name: Create GitHub Release
runs-on: ubuntu-24.04
permissions:
contents: write
id-token: write
steps:
- name: Trigger publish
uses: bitwarden/gh-actions/trigger-actions@main
- name: Check out repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
azure_subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
azure_tenant_id: ${{ secrets.AZURE_TENANT_ID }}
azure_client_id: ${{ secrets.AZURE_CLIENT_ID }}
task: release-android
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"

View File

@@ -2,7 +2,6 @@ package com.x8bit.bitwarden.data.auth.repository
import com.bitwarden.core.AuthRequestMethod
import com.bitwarden.core.InitUserCryptoMethod
import com.bitwarden.core.MasterPasswordUnlockData
import com.bitwarden.core.RegisterTdeKeyResponse
import com.bitwarden.core.WrappedAccountCryptographicState
import com.bitwarden.core.data.manager.dispatcher.DispatcherManager
@@ -109,7 +108,6 @@ import com.x8bit.bitwarden.data.auth.repository.util.activeUserIdChangesFlow
import com.x8bit.bitwarden.data.auth.repository.util.policyInformation
import com.x8bit.bitwarden.data.auth.repository.util.toAccountCryptographicState
import com.x8bit.bitwarden.data.auth.repository.util.toDeviceInfo
import com.x8bit.bitwarden.data.auth.repository.util.toKdfRequestModel
import com.x8bit.bitwarden.data.auth.repository.util.toOrganizations
import com.x8bit.bitwarden.data.auth.repository.util.toRemovedPasswordUserStateJson
import com.x8bit.bitwarden.data.auth.repository.util.toSdkParams
@@ -1080,14 +1078,16 @@ class AuthRepositoryImpl(
newPassword: String,
passwordHint: String?,
): ResetPasswordResult {
val profile = authDiskSource.userState?.activeAccount?.profile
val activeAccount = authDiskSource
.userState
?.activeAccount
?: return ResetPasswordResult.Error(error = NoActiveUserException())
val currentPasswordHash = currentPassword?.let { password ->
authSdkSource
.hashPassword(
email = profile.email,
email = activeAccount.profile.email,
password = password,
kdf = profile.toSdkParams(),
kdf = activeAccount.profile.toSdkParams(),
purpose = HashPurpose.SERVER_AUTHORIZATION,
)
.fold(
@@ -1095,32 +1095,48 @@ class AuthRepositoryImpl(
onSuccess = { it },
)
}
val userId = profile.userId
val userId = activeAccount.profile.userId
return vaultSdkSource
.updatePassword(
userId = userId,
newPassword = newPassword,
)
.flatMap { response ->
accountsService.resetPassword(
body = ResetPasswordRequestJson(
currentPasswordHash = currentPasswordHash,
passwordHint = passwordHint,
kdf = profile.toKdfRequestModel(),
salt = profile.email,
masterPasswordAuthenticationHash = response.passwordHash,
masterKeyWrappedUserKey = response.newKey,
),
)
}
.onSuccess {
toastManager.show(BitwardenString.updated_master_password)
// Log out the user after successful password reset. This clears all
// user data, so there is no need to store any of the updated info.
logout(reason = LogoutReason.PasswordReset, userId = userId)
.flatMap { updatePasswordResponse ->
accountsService
.resetPassword(
body = ResetPasswordRequestJson(
currentPasswordHash = currentPasswordHash,
newPasswordHash = updatePasswordResponse.passwordHash,
passwordHint = passwordHint,
key = updatePasswordResponse.newKey,
),
)
}
.fold(
onSuccess = { ResetPasswordResult.Success },
onSuccess = {
// Update the saved master password hash.
authSdkSource
.hashPassword(
email = activeAccount.profile.email,
password = newPassword,
kdf = activeAccount.profile.toSdkParams(),
purpose = HashPurpose.LOCAL_AUTHORIZATION,
)
.onSuccess { passwordHash ->
authDiskSource.storeMasterPasswordHash(
userId = userId,
passwordHash = passwordHash,
)
}
toastManager.show(BitwardenString.updated_master_password)
// Log out the user after successful password reset.
// This clears all user state including forcePasswordResetReason.
logout(reason = LogoutReason.PasswordReset, userId = userId)
// Return the success.
ResetPasswordResult.Success
},
onFailure = { ResetPasswordResult.Error(error = it) },
)
}
@@ -1129,10 +1145,10 @@ class AuthRepositoryImpl(
organizationIdentifier: String,
password: String,
passwordHint: String?,
): SetPasswordResult = userStateManager.userStateTransaction {
): SetPasswordResult {
val profile = authDiskSource.userState?.activeAccount?.profile
?: return@userStateTransaction SetPasswordResult.Error(error = NoActiveUserException())
return@userStateTransaction when (profile.forcePasswordResetReason) {
?: return SetPasswordResult.Error(error = NoActiveUserException())
return when (profile.forcePasswordResetReason) {
ForcePasswordResetReason.TDE_USER_WITHOUT_PASSWORD_HAS_PASSWORD_RESET_PERMISSION -> {
setUpdatedPassword(
profile = profile,
@@ -1168,34 +1184,42 @@ class AuthRepositoryImpl(
.flatMap { response ->
accountsService
.setPassword(
body = SetPasswordRequestJson.V2(
body = SetPasswordRequestJson(
passwordHash = response.passwordHash,
passwordHint = passwordHint,
organizationIdentifier = organizationIdentifier,
kdf = profile.toKdfRequestModel(),
salt = profile.email,
masterPasswordAuthenticationHash = response.passwordHash,
masterKeyWrappedUserKey = response.newKey,
kdfIterations = profile.kdfIterations,
kdfMemory = profile.kdfMemory,
kdfParallelism = profile.kdfParallelism,
kdfType = profile.kdfType,
key = response.newKey,
keys = null,
),
)
.map { response }
.map { response.passwordHash }
}
.onSuccess { response ->
.flatMap { masterPasswordHash ->
when (val result = vaultRepository.unlockVaultWithMasterPassword(password)) {
is VaultUnlockResult.Success -> {
enrollUserInPasswordReset(
userId = userId,
organizationIdentifier = organizationIdentifier,
passwordHash = masterPasswordHash,
)
}
is VaultUnlockError -> {
(result.error ?: IllegalStateException("Failed to unlock vault"))
.asFailure()
}
}
}
.onSuccess {
authDiskSource.userState = authDiskSource.userState?.toUserStateJsonWithPassword(
masterPasswordUnlock = MasterPasswordUnlockData(
kdf = profile.toSdkParams(),
masterKeyWrappedUserKey = response.newKey,
salt = profile.email,
),
masterPasswordUnlock = null,
)
this.organizationIdentifier = null
}
.flatMap { response ->
enrollUserInPasswordReset(
userId = userId,
organizationIdentifier = organizationIdentifier,
passwordHash = response.passwordHash,
)
}
.fold(
onFailure = { SetPasswordResult.Error(error = it) },
onSuccess = { SetPasswordResult.Success },
@@ -1280,7 +1304,7 @@ class AuthRepositoryImpl(
.flatMap { response ->
accountsService
.setPassword(
body = SetPasswordRequestJson.V1(
body = SetPasswordRequestJson(
passwordHash = response.masterPasswordHash,
passwordHint = passwordHint,
organizationIdentifier = organizationIdentifier,
@@ -1289,7 +1313,7 @@ class AuthRepositoryImpl(
kdfParallelism = profile.kdfParallelism,
kdfType = profile.kdfType,
key = response.encryptedUserKey,
keys = SetPasswordRequestJson.V1.Keys(
keys = SetPasswordRequestJson.Keys(
publicKey = response.keys.public,
encryptedPrivateKey = response.keys.private,
),
@@ -1302,26 +1326,16 @@ class AuthRepositoryImpl(
privateKey = response.keys.private,
),
)
authDiskSource.userState = authDiskSource
.userState
?.toUserStateJsonWithPassword(
masterPasswordUnlock = MasterPasswordUnlockData(
kdf = profile.toSdkParams(),
masterKeyWrappedUserKey = response.encryptedUserKey,
salt = profile.email,
),
)
this.organizationIdentifier = null
}
.map { response }
.map { response.masterPasswordHash }
}
.flatMap { response ->
.flatMap { masterPasswordHash ->
when (val result = vaultRepository.unlockVaultWithMasterPassword(password)) {
is VaultUnlockResult.Success -> {
enrollUserInPasswordReset(
userId = userId,
organizationIdentifier = organizationIdentifier,
passwordHash = response.masterPasswordHash,
passwordHash = masterPasswordHash,
)
}
@@ -1331,6 +1345,12 @@ class AuthRepositoryImpl(
}
}
}
.onSuccess {
authDiskSource.userState = authDiskSource.userState?.toUserStateJsonWithPassword(
masterPasswordUnlock = null,
)
this.organizationIdentifier = null
}
.fold(
onFailure = { SetPasswordResult.Error(error = it) },
onSuccess = { SetPasswordResult.Success },

View File

@@ -1,7 +1,6 @@
package com.x8bit.bitwarden.data.auth.repository.util
import com.bitwarden.crypto.Kdf
import com.bitwarden.network.model.KdfJson
import com.bitwarden.network.model.KdfTypeJson
import com.x8bit.bitwarden.data.auth.datasource.disk.model.AccountJson
import com.x8bit.bitwarden.data.auth.util.KdfParamsConstants
@@ -14,8 +13,8 @@ fun AccountJson.Profile.toSdkParams(): Kdf {
KdfTypeJson.ARGON2_ID -> Kdf.Argon2id(
iterations = (kdfIterations ?: KdfParamsConstants.DEFAULT_ARGON2_ITERATIONS).toUInt(),
memory = (kdfMemory ?: KdfParamsConstants.DEFAULT_ARGON2_MEMORY).toUInt(),
parallelism = (kdfParallelism ?: KdfParamsConstants.DEFAULT_ARGON2_PARALLELISM)
.toUInt(),
parallelism =
(kdfParallelism ?: KdfParamsConstants.DEFAULT_ARGON2_PARALLELISM).toUInt(),
)
KdfTypeJson.PBKDF2_SHA256 -> Kdf.Pbkdf2(
@@ -25,23 +24,3 @@ fun AccountJson.Profile.toSdkParams(): Kdf {
else -> Kdf.Pbkdf2(iterations = KdfParamsConstants.DEFAULT_PBKDF2_ITERATIONS.toUInt())
}
}
/**
* Convert [AccountJson.Profile] to [KdfJson] params for use with Bitwarden network requests.
*/
fun AccountJson.Profile.toKdfRequestModel(): KdfJson =
when (val kdfType = this.kdfType ?: KdfTypeJson.PBKDF2_SHA256) {
KdfTypeJson.ARGON2_ID -> KdfJson(
kdfType = kdfType,
iterations = this.kdfIterations ?: KdfParamsConstants.DEFAULT_ARGON2_ITERATIONS,
memory = this.kdfMemory ?: KdfParamsConstants.DEFAULT_ARGON2_MEMORY,
parallelism = this.kdfParallelism ?: KdfParamsConstants.DEFAULT_ARGON2_PARALLELISM,
)
KdfTypeJson.PBKDF2_SHA256 -> KdfJson(
kdfType = kdfType,
iterations = this.kdfIterations ?: KdfParamsConstants.DEFAULT_PBKDF2_ITERATIONS,
memory = this.kdfMemory,
parallelism = this.kdfParallelism,
)
}

View File

@@ -10,7 +10,6 @@ import com.bitwarden.network.model.UserDecryptionOptionsJson
import com.bitwarden.policies.PolicyType
import com.bitwarden.policies.PolicyView
import com.bitwarden.ui.platform.base.util.toHexColorRepresentation
import com.x8bit.bitwarden.data.auth.datasource.disk.model.ForcePasswordResetReason
import com.x8bit.bitwarden.data.auth.datasource.disk.model.OnboardingStatus
import com.x8bit.bitwarden.data.auth.datasource.disk.model.UserStateJson
import com.x8bit.bitwarden.data.auth.datasource.sdk.util.toKdfRequestModel
@@ -85,52 +84,35 @@ fun UserStateJson.toUpdatedUserStateJson(
}
?: profile
.userDecryptionOptions
?.copy(
hasMasterPassword = false,
masterPasswordUnlock = null,
)
val forcePasswordResetReason = syncProfile.getForcePasswordResetReason(
userDecryptionOptions = userDecryptionOptions,
previousForcePasswordResetReason = profile.forcePasswordResetReason,
)
val updatedProfile = profile.copy(
forcePasswordResetReason = forcePasswordResetReason,
avatarColorHex = syncProfile.avatarColor,
stamp = syncProfile.securityStamp,
hasPremiumPersonally = syncProfile.isPremium,
hasPremiumFromOrganization = syncProfile.isPremiumFromOrganization,
isTwoFactorEnabled = syncProfile.isTwoFactorEnabled,
creationDate = syncProfile.creationDate,
userDecryptionOptions = userDecryptionOptions,
kdfType = masterPasswordUnlockKdf?.kdfType ?: profile.kdfType,
kdfIterations = masterPasswordUnlockKdf?.iterations ?: profile.kdfIterations,
kdfMemory = masterPasswordUnlockKdf?.memory ?: profile.kdfMemory,
kdfParallelism = masterPasswordUnlockKdf?.parallelism ?: profile.kdfParallelism,
)
val updatedAccount = account.copy(profile = updatedProfile)
return this.copy(
accounts = accounts
.toMutableMap()
.apply { replace(userId, updatedAccount) },
)
}
?.copy(masterPasswordUnlock = null)
private fun SyncResponseJson.Profile.getForcePasswordResetReason(
userDecryptionOptions: UserDecryptionOptionsJson?,
previousForcePasswordResetReason: ForcePasswordResetReason?,
): ForcePasswordResetReason? {
val hasManageResetPasswordPermission = this.organizations.orEmpty().any {
it.type == OrganizationType.OWNER ||
it.type == OrganizationType.ADMIN ||
it.permissions.shouldManageResetPassword
}
return ForcePasswordResetReason
.TDE_USER_WITHOUT_PASSWORD_HAS_PASSWORD_RESET_PERMISSION
.takeIf {
userDecryptionOptions?.hasMasterPassword == false &&
hasManageResetPasswordPermission
}
?: previousForcePasswordResetReason
val updatedProfile = profile
.copy(
avatarColorHex = syncProfile.avatarColor,
stamp = syncProfile.securityStamp,
hasPremiumPersonally = syncProfile.isPremium,
hasPremiumFromOrganization = syncProfile.isPremiumFromOrganization,
isTwoFactorEnabled = syncProfile.isTwoFactorEnabled,
creationDate = syncProfile.creationDate,
userDecryptionOptions = userDecryptionOptions,
kdfType = masterPasswordUnlockKdf?.kdfType
?: profile.kdfType,
kdfIterations = masterPasswordUnlockKdf?.iterations
?: profile.kdfIterations,
kdfMemory = masterPasswordUnlockKdf?.memory
?: profile.kdfMemory,
kdfParallelism = masterPasswordUnlockKdf?.parallelism
?: profile.kdfParallelism,
)
val updatedAccount = account.copy(profile = updatedProfile)
return this
.copy(
accounts = accounts
.toMutableMap()
.apply {
replace(userId, updatedAccount)
},
)
}
/**
@@ -138,16 +120,20 @@ private fun SyncResponseJson.Profile.getForcePasswordResetReason(
* their password.
*/
fun UserStateJson.toUserStateJsonWithPassword(
masterPasswordUnlock: MasterPasswordUnlockData,
masterPasswordUnlock: MasterPasswordUnlockData?,
): UserStateJson {
val account = this.activeAccount
val profile = account.profile
val userDecryptionOptions = profile.userDecryptionOptions
val masterPasswordUnlockJson = MasterPasswordUnlockDataJson(
salt = masterPasswordUnlock.salt,
kdf = masterPasswordUnlock.kdf.toKdfRequestModel(),
masterKeyWrappedUserKey = masterPasswordUnlock.masterKeyWrappedUserKey,
)
val masterPasswordUnlockJson = masterPasswordUnlock
?.let {
MasterPasswordUnlockDataJson(
salt = it.salt,
kdf = it.kdf.toKdfRequestModel(),
masterKeyWrappedUserKey = it.masterKeyWrappedUserKey,
)
}
?: userDecryptionOptions?.masterPasswordUnlock
val updatedProfile = profile
.copy(
forcePasswordResetReason = null,

View File

@@ -1,6 +1,5 @@
package com.x8bit.bitwarden.data.billing.manager
import com.x8bit.bitwarden.data.billing.model.PremiumCard
import com.x8bit.bitwarden.data.billing.repository.model.SubscriptionStatusState
import com.x8bit.bitwarden.data.billing.repository.model.UpgradeLifecycleState
import kotlinx.coroutines.flow.StateFlow
@@ -17,9 +16,10 @@ const val UPGRADED_TO_PREMIUM_LEARN_MORE_URL: String =
interface PremiumStateManager {
/**
* Emits a [PremiumCard] for the current user indicating what Premium card should be displayed.
* Emits `true` when the current user is eligible to see the Premium upgrade banner,
* or `false` otherwise.
*/
val premiumCardStateFlow: StateFlow<PremiumCard>
val isPremiumUpgradeBannerEligibleFlow: StateFlow<Boolean>
/**
* Emits `true` while the active user is eligible to see the "Upgraded to Premium" action

View File

@@ -5,10 +5,8 @@ import com.bitwarden.core.data.manager.model.FlagKey
import com.bitwarden.core.data.repository.model.DataState
import com.bitwarden.data.repository.model.Environment
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
import com.x8bit.bitwarden.data.auth.datasource.disk.model.AccountJson
import com.x8bit.bitwarden.data.auth.datasource.disk.model.UserStateJson
import com.x8bit.bitwarden.data.auth.repository.util.activeUserIdChangesFlow
import com.x8bit.bitwarden.data.billing.model.PremiumCard
import com.x8bit.bitwarden.data.billing.repository.BillingRepository
import com.x8bit.bitwarden.data.billing.repository.model.PremiumSubscriptionStatus
import com.x8bit.bitwarden.data.billing.repository.model.SubscriptionResult
@@ -52,7 +50,7 @@ class PremiumStateManagerImpl(
private val settingsDiskSource: SettingsDiskSource,
vaultRepository: VaultRepository,
private val featureFlagManager: FeatureFlagManager,
environmentRepository: EnvironmentRepository,
private val environmentRepository: EnvironmentRepository,
pushManager: PushManager,
private val clock: Clock,
dispatcherManager: DispatcherManager,
@@ -125,69 +123,62 @@ class PremiumStateManagerImpl(
)
@OptIn(ExperimentalCoroutinesApi::class)
override val premiumCardStateFlow: StateFlow<PremiumCard> =
override val isPremiumUpgradeBannerEligibleFlow: StateFlow<Boolean> =
combine(
authDiskSource.userStateFlow.map { it?.activeAccount },
authDiskSource.userStateFlow,
billingRepository.isInAppBillingSupportedFlow,
featureFlagManager.getFeatureFlagFlow(FlagKey.MobilePremiumUpgrade),
authDiskSource.activeUserIdChangesFlow.flatMapLatest { userId ->
userId
?.let { id ->
settingsDiskSource
.getPremiumUpgradeBannerDismissedFlow(id)
.map { it ?: false }
}
?: flowOf(false)
},
authDiskSource.activeUserIdChangesFlow
.flatMapLatest { userId ->
userId
?.let { id ->
settingsDiskSource
.getPremiumUpgradeBannerDismissedFlow(id)
.map { it ?: false }
}
?: flowOf(false)
},
vaultRepository.vaultDataStateFlow,
) {
account,
userState,
isInAppBillingSupported,
featureFlagEnabled,
isUpgradeCardDismissed,
isDismissed,
vaultDataState,
->
BannerInputs(
account = account,
userState = userState,
isInAppBillingSupported = isInAppBillingSupported,
featureFlagEnabled = featureFlagEnabled,
isUpgradeCardDismissed = isUpgradeCardDismissed,
isDismissed = isDismissed,
vaultDataState = vaultDataState,
)
}
.combine(upgradeLifecycleStateFlow) { inputs, lifecycle ->
val profile = inputs.account?.profile ?: return@combine PremiumCard.NONE
if (!inputs.featureFlagEnabled) return@combine PremiumCard.NONE
val initialCard = when (lifecycle) {
UpgradeLifecycleState.Free -> PremiumCard.UPGRADE
UpgradeLifecycleState.UpgradePending -> PremiumCard.NONE
is UpgradeLifecycleState.Premium -> {
lifecycle.subscriptionStatus.premiumCardState()
}
}
when (initialCard) {
PremiumCard.UPGRADE -> {
val isAccountOldEnough = profile.creationDate.isOlderThanDays(
days = PREMIUM_UPGRADE_MINIMUM_ACCOUNT_AGE_DAYS,
clock = clock,
val profile = inputs.userState?.activeAccount?.profile
?: return@combine false
val isAccountOldEnough = profile.creationDate.isOlderThanDays(
days = PREMIUM_UPGRADE_MINIMUM_ACCOUNT_AGE_DAYS,
clock = clock,
)
val itemCount = inputs.vaultDataState.activeVaultItemCount()
val lifecycleAllowsBanner = lifecycle is UpgradeLifecycleState.Free ||
(
lifecycle is UpgradeLifecycleState.Premium &&
lifecycle.subscriptionStatus.isInTroubleState()
)
val itemCount = inputs.vaultDataState.activeVaultItemCount()
val showCard = inputs.isInAppBillingSupported &&
isAccountOldEnough &&
itemCount >= PREMIUM_UPGRADE_MINIMUM_VAULT_ITEMS &&
!inputs.isUpgradeCardDismissed
initialCard.takeIf { showCard } ?: PremiumCard.NONE
}
PremiumCard.NEEDS_ATTENTION,
PremiumCard.NONE,
-> initialCard
}
lifecycleAllowsBanner &&
inputs.isInAppBillingSupported &&
inputs.featureFlagEnabled &&
!inputs.isDismissed &&
isAccountOldEnough &&
itemCount >= PREMIUM_UPGRADE_MINIMUM_VAULT_ITEMS
}
.stateIn(
scope = unconfinedScope,
started = SharingStarted.Eagerly,
initialValue = PremiumCard.NONE,
initialValue = false,
)
override val isSelfHostedFlow: StateFlow<Boolean> =
@@ -398,41 +389,32 @@ class PremiumStateManagerImpl(
}
private data class BannerInputs(
val account: AccountJson?,
val userState: UserStateJson?,
val isInAppBillingSupported: Boolean,
val featureFlagEnabled: Boolean,
val isUpgradeCardDismissed: Boolean,
val isDismissed: Boolean,
val vaultDataState: DataState<VaultData>,
)
/**
* Returns a [PremiumCard] for the given [SubscriptionStatusState] and subscription substate.
* Returns `true` when the given [SubscriptionStatusState] represents a subscription substate
* that should disqualify a user from being treated as effectively premium.
*/
private fun SubscriptionStatusState.premiumCardState(): PremiumCard =
when (this) {
is SubscriptionStatusState.Available -> {
when (this.status) {
PremiumSubscriptionStatus.PAST_DUE,
PremiumSubscriptionStatus.UPDATE_PAYMENT,
-> PremiumCard.NEEDS_ATTENTION
private fun SubscriptionStatusState.isInTroubleState(): Boolean =
this is SubscriptionStatusState.Available &&
when (this.status) {
PremiumSubscriptionStatus.CANCELED,
PremiumSubscriptionStatus.EXPIRED,
PremiumSubscriptionStatus.PAST_DUE,
PremiumSubscriptionStatus.PAUSED,
PremiumSubscriptionStatus.UPDATE_PAYMENT,
-> true
PremiumSubscriptionStatus.EXPIRED,
PremiumSubscriptionStatus.PAUSED,
-> PremiumCard.UPGRADE
PremiumSubscriptionStatus.ACTIVE,
PremiumSubscriptionStatus.CANCELED,
PremiumSubscriptionStatus.PENDING_CANCELLATION,
-> PremiumCard.NONE
}
PremiumSubscriptionStatus.ACTIVE,
PremiumSubscriptionStatus.PENDING_CANCELLATION,
-> false
}
is SubscriptionStatusState.Error,
SubscriptionStatusState.Loading,
SubscriptionStatusState.NoSubscription,
-> PremiumCard.NONE
}
/**
* Returns `true` if this [Instant] is older than the given number of [days] based on
* the provided [clock]. Returns `false` if the receiver is `null`.

View File

@@ -1,10 +0,0 @@
package com.x8bit.bitwarden.data.billing.model
/**
* Represents which premium card should be displayed.
*/
enum class PremiumCard {
UPGRADE,
NEEDS_ATTENTION,
NONE,
}

View File

@@ -2,7 +2,6 @@ package com.x8bit.bitwarden.data.platform.repository
import android.view.autofill.AutofillManager
import com.bitwarden.authenticatorbridge.util.generateSecretKey
import com.bitwarden.core.data.manager.BuildInfoManager
import com.bitwarden.core.data.manager.dispatcher.DispatcherManager
import com.bitwarden.data.manager.flightrecorder.FlightRecorderManager
import com.bitwarden.policies.PolicyType
@@ -52,7 +51,6 @@ class SettingsRepositoryImpl(
private val autofillManager: AutofillManager,
private val autofillEnabledManager: AutofillEnabledManager,
private val authDiskSource: AuthDiskSource,
private val buildInfoManager: BuildInfoManager,
private val settingsDiskSource: SettingsDiskSource,
private val vaultSdkSource: VaultSdkSource,
flightRecorderManager: FlightRecorderManager,
@@ -377,12 +375,11 @@ class SettingsRepositoryImpl(
override val hasShownAccessibilityDisclaimerFlow: StateFlow<Boolean>
get() = settingsDiskSource
.hasShownAccessibilityDisclaimerFlow
.map { buildInfoManager.isFdroid || it ?: false }
.map { it ?: false }
.stateIn(
scope = unconfinedScope,
started = SharingStarted.Lazily,
initialValue = buildInfoManager.isFdroid ||
settingsDiskSource.hasShownAccessibilityDisclaimer ?: false,
initialValue = settingsDiskSource.hasShownAccessibilityDisclaimer ?: false,
)
init {

View File

@@ -1,7 +1,6 @@
package com.x8bit.bitwarden.data.platform.repository.di
import android.view.autofill.AutofillManager
import com.bitwarden.core.data.manager.BuildInfoManager
import com.bitwarden.core.data.manager.dispatcher.DispatcherManager
import com.bitwarden.data.manager.flightrecorder.FlightRecorderManager
import com.bitwarden.data.repository.ServerConfigRepository
@@ -68,7 +67,6 @@ object PlatformRepositoryModule {
autofillManager: AutofillManager,
autofillEnabledManager: AutofillEnabledManager,
authDiskSource: AuthDiskSource,
buildInfoManager: BuildInfoManager,
settingsDiskSource: SettingsDiskSource,
vaultSdkSource: VaultSdkSource,
accessibilityEnabledManager: AccessibilityEnabledManager,
@@ -80,7 +78,6 @@ object PlatformRepositoryModule {
autofillManager = autofillManager,
autofillEnabledManager = autofillEnabledManager,
authDiskSource = authDiskSource,
buildInfoManager = buildInfoManager,
settingsDiskSource = settingsDiskSource,
vaultSdkSource = vaultSdkSource,
accessibilityEnabledManager = accessibilityEnabledManager,

View File

@@ -201,7 +201,7 @@ private fun SearchDialogs(
when (dialogState) {
SearchState.DialogState.ArchiveRequiresPremium -> {
BitwardenTwoButtonDialog(
title = stringResource(id = BitwardenString.premium_subscription_required),
title = stringResource(id = BitwardenString.archive_unavailable),
message = stringResource(id = BitwardenString.archiving_items_is_a_premium_feature),
confirmButtonText = stringResource(id = BitwardenString.upgrade_to_premium),
dismissButtonText = stringResource(id = BitwardenString.cancel),

View File

@@ -349,9 +349,15 @@ class SendViewModel @Inject constructor(
return
}
if (!state.isPremiumUser) {
mutableStateFlow.update {
it.copy(dialogState = SendState.DialogState.FileTypeRequiresPremium)
val dialog = if (premiumStateManager.isInAppUpgradeAvailable()) {
SendState.DialogState.FileTypeRequiresPremium
} else {
SendState.DialogState.Error(
title = BitwardenString.send.asText(),
message = BitwardenString.send_file_premium_required.asText(),
)
}
mutableStateFlow.update { it.copy(dialogState = dialog) }
return
}
}

View File

@@ -201,10 +201,12 @@ private fun AddEditSendDialogs(
onUpgradeToPremiumClick: () -> Unit,
) {
when (dialogState) {
is AddEditSendState.DialogState.PremiumRequired -> {
is AddEditSendState.DialogState.EmailAuthRequiresPremium -> {
BitwardenTwoButtonDialog(
title = stringResource(id = BitwardenString.premium_subscription_required),
message = dialogState.message(),
message = stringResource(
id = BitwardenString.sharing_with_specific_people_is_a_premium_feature,
),
confirmButtonText = stringResource(id = BitwardenString.upgrade_to_premium),
dismissButtonText = stringResource(id = BitwardenString.cancel),
onConfirmClick = onUpgradeToPremiumClick,

View File

@@ -548,13 +548,7 @@ class AddEditSendViewModel @Inject constructor(
// Check if user is trying to select Email auth without Premium
if (action.sendAuth is SendAuth.Email && !state.isPremium) {
mutableStateFlow.update {
it.copy(
dialogState = AddEditSendState.DialogState.PremiumRequired(
message = BitwardenString
.sharing_with_specific_people_is_a_premium_feature
.asText(),
),
)
it.copy(dialogState = AddEditSendState.DialogState.EmailAuthRequiresPremium)
}
return
}
@@ -707,7 +701,8 @@ class AddEditSendViewModel @Inject constructor(
// check just in case.
mutableStateFlow.update {
it.copy(
dialogState = AddEditSendState.DialogState.PremiumRequired(
dialogState = AddEditSendState.DialogState.Error(
title = BitwardenString.send.asText(),
message = BitwardenString.send_file_premium_required.asText(),
),
)
@@ -1041,12 +1036,11 @@ data class AddEditSendState(
) : DialogState()
/**
* Displays a dialog to the user indicating that a Premium account is required.
* Displays a dialog to the user indicating that email authentication requires
* a Premium account.
*/
@Parcelize
data class PremiumRequired(
val message: Text,
) : DialogState()
data object EmailAuthRequiresPremium : DialogState()
}
}

View File

@@ -138,12 +138,11 @@ fun LazyListScope.vaultAddEditIdentityItems(
)
}
item {
BitwardenPasswordField(
BitwardenTextField(
label = stringResource(id = BitwardenString.passport_number),
value = identityState.passportNumber,
onValueChange = identityItemTypeHandlers.onPassportNumberTextChange,
showPasswordTestTag = "IdentityShowPassportNumberButton",
passwordFieldTestTag = "IdentityPassportNumberEntry",
textFieldTestTag = "IdentityPassportNumberEntry",
cardStyle = CardStyle.Middle(),
modifier = Modifier
.fillMaxWidth()

View File

@@ -87,10 +87,10 @@ import com.x8bit.bitwarden.ui.vault.feature.addedit.handlers.VaultAddEditCardTyp
import com.x8bit.bitwarden.ui.vault.feature.addedit.handlers.VaultAddEditCommonHandlers
import com.x8bit.bitwarden.ui.vault.feature.addedit.handlers.VaultAddEditIdentityTypeHandlers
import com.x8bit.bitwarden.ui.vault.feature.addedit.handlers.VaultAddEditLicenseTypeHandlers
import com.x8bit.bitwarden.ui.vault.feature.addedit.handlers.rememberVaultAddEditPassportTypeHandlers
import com.x8bit.bitwarden.ui.vault.feature.addedit.handlers.VaultAddEditLoginTypeHandlers
import com.x8bit.bitwarden.ui.vault.feature.addedit.handlers.VaultAddEditSshKeyTypeHandlers
import com.x8bit.bitwarden.ui.vault.feature.addedit.handlers.VaultAddEditUserVerificationHandlers
import com.x8bit.bitwarden.ui.vault.feature.addedit.handlers.rememberVaultAddEditPassportTypeHandlers
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.launch
@@ -500,7 +500,7 @@ private fun VaultAddEditItemDialogs(
when (dialogState) {
is VaultAddEditState.DialogState.ArchiveRequiresPremium -> {
BitwardenTwoButtonDialog(
title = stringResource(id = BitwardenString.premium_subscription_required),
title = stringResource(id = BitwardenString.archive_unavailable),
message = stringResource(id = BitwardenString.archiving_items_is_a_premium_feature),
confirmButtonText = stringResource(id = BitwardenString.upgrade_to_premium),
dismissButtonText = stringResource(id = BitwardenString.cancel),

View File

@@ -140,7 +140,7 @@ private fun AttachmentsDialogs(
) {
when (dialogState) {
AttachmentsState.DialogState.RequiresPremium -> BitwardenTwoButtonDialog(
title = stringResource(id = BitwardenString.premium_subscription_required),
title = stringResource(id = BitwardenString.attachments_unavailable),
message = stringResource(id = BitwardenString.attachments_are_a_premium_feature),
confirmButtonText = stringResource(id = BitwardenString.upgrade_to_premium),
onConfirmClick = attachmentsHandlers.onUpgradeToPremiumClick,

View File

@@ -47,11 +47,11 @@ import com.x8bit.bitwarden.ui.vault.feature.item.handlers.VaultBankAccountItemTy
import com.x8bit.bitwarden.ui.vault.feature.item.handlers.VaultCardItemTypeHandlers
import com.x8bit.bitwarden.ui.vault.feature.item.handlers.VaultCommonItemTypeHandlers
import com.x8bit.bitwarden.ui.vault.feature.item.handlers.VaultDriversLicenseItemTypeHandlers
import com.x8bit.bitwarden.ui.vault.feature.item.handlers.VaultPassportItemTypeHandlers
import com.x8bit.bitwarden.ui.vault.feature.item.handlers.rememberVaultPassportItemTypeHandlers
import com.x8bit.bitwarden.ui.vault.feature.item.handlers.VaultIdentityItemTypeHandlers
import com.x8bit.bitwarden.ui.vault.feature.item.handlers.VaultLoginItemTypeHandlers
import com.x8bit.bitwarden.ui.vault.feature.item.handlers.VaultPassportItemTypeHandlers
import com.x8bit.bitwarden.ui.vault.feature.item.handlers.VaultSshKeyItemTypeHandlers
import com.x8bit.bitwarden.ui.vault.feature.item.handlers.rememberVaultPassportItemTypeHandlers
import com.x8bit.bitwarden.ui.vault.model.VaultAddEditType
/**
@@ -313,7 +313,7 @@ private fun VaultItemDialogs(
when (dialog) {
is VaultItemState.DialogState.ArchiveRequiresPremium -> {
BitwardenTwoButtonDialog(
title = stringResource(id = BitwardenString.premium_subscription_required),
title = stringResource(id = BitwardenString.archive_unavailable),
message = stringResource(id = BitwardenString.archiving_items_is_a_premium_feature),
confirmButtonText = stringResource(id = BitwardenString.upgrade_to_premium),
dismissButtonText = stringResource(id = BitwardenString.cancel),

View File

@@ -121,7 +121,7 @@ fun VaultItemAttachment(
if (shouldShowPremiumWarningDialog) {
BitwardenTwoButtonDialog(
title = stringResource(id = BitwardenString.premium_subscription_required),
title = stringResource(id = BitwardenString.attachments_unavailable),
message = stringResource(id = BitwardenString.attachments_are_a_premium_feature),
confirmButtonText = stringResource(id = BitwardenString.upgrade_to_premium),
dismissButtonText = stringResource(id = BitwardenString.cancel),

View File

@@ -408,7 +408,7 @@ private fun VaultItemListingDialogs(
is VaultItemListingState.DialogState.ArchiveRequiresPremium -> {
BitwardenTwoButtonDialog(
title = stringResource(id = BitwardenString.premium_subscription_required),
title = stringResource(id = BitwardenString.archive_unavailable),
message = stringResource(id = BitwardenString.archiving_items_is_a_premium_feature),
confirmButtonText = stringResource(id = BitwardenString.upgrade_to_premium),
dismissButtonText = stringResource(id = BitwardenString.cancel),

View File

@@ -562,7 +562,6 @@ fun VaultContent(
}
}
@Suppress("LongMethod")
@Composable
private fun ActionCard(
actionCardState: VaultState.ActionCardState,
@@ -600,23 +599,13 @@ private fun ActionCard(
id = BitwardenString
.a_premium_plan_gives_you_more_tools_to_stay_secure_and_in_control,
),
actionText = stringResource(id = BitwardenString.learn_more),
actionText = stringResource(id = BitwardenString.upgrade_to_premium),
onActionClick = { vaultHandlers.actionCardClick(actionCardState) },
onDismissClick = { vaultHandlers.dismissActionCardClick(actionCardState) },
modifier = modifier,
)
}
VaultState.ActionCardState.PremiumNeedsAttention -> {
BitwardenActionCard(
cardTitle = stringResource(id = BitwardenString.your_subscription_needs_attention),
cardSubtitle = stringResource(id = BitwardenString.check_your_plan_for_details),
actionText = stringResource(id = BitwardenString.view_plan),
onActionClick = { vaultHandlers.actionCardClick(actionCardState) },
modifier = modifier,
)
}
VaultState.ActionCardState.IntroducingArchive -> {
BitwardenActionCard(
cardTitle = stringResource(id = BitwardenString.introducing_archive),

View File

@@ -397,7 +397,7 @@ private fun VaultDialogs(
when (dialogState) {
VaultState.DialogState.ArchiveRequiresPremium -> {
BitwardenTwoButtonDialog(
title = stringResource(id = BitwardenString.premium_subscription_required),
title = stringResource(id = BitwardenString.archive_unavailable),
message = stringResource(id = BitwardenString.archiving_items_is_a_premium_feature),
confirmButtonText = stringResource(id = BitwardenString.upgrade_to_premium),
dismissButtonText = stringResource(id = BitwardenString.cancel),

View File

@@ -36,7 +36,6 @@ import com.x8bit.bitwarden.data.auth.repository.model.ValidatePasswordResult
import com.x8bit.bitwarden.data.autofill.manager.browser.BrowserAutofillDialogManager
import com.x8bit.bitwarden.data.billing.manager.PremiumStateManager
import com.x8bit.bitwarden.data.billing.manager.UPGRADED_TO_PREMIUM_LEARN_MORE_URL
import com.x8bit.bitwarden.data.billing.model.PremiumCard
import com.x8bit.bitwarden.data.platform.manager.CredentialExchangeRegistryManager
import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager
import com.x8bit.bitwarden.data.platform.manager.FirstTimeActionManager
@@ -121,7 +120,7 @@ class VaultViewModel @Inject constructor(
private val networkConnectionManager: NetworkConnectionManager,
private val browserAutofillDialogManager: BrowserAutofillDialogManager,
private val credentialExchangeRegistryManager: CredentialExchangeRegistryManager,
buildInfoManager: BuildInfoManager,
private val buildInfoManager: BuildInfoManager,
featureFlagManager: FeatureFlagManager,
snackbarRelayManager: SnackbarRelayManager<SnackbarRelay>,
) : BaseViewModel<VaultState, VaultEvent, VaultAction>(
@@ -269,10 +268,10 @@ class VaultViewModel @Inject constructor(
.launchIn(viewModelScope)
premiumStateManager
.premiumCardStateFlow
.isPremiumUpgradeBannerEligibleFlow
.map {
VaultAction.Internal.PremiumUpgradeBannerEligibilityReceive(
premiumCard = it,
isEligible = it,
)
}
.onEach(::sendAction)
@@ -450,10 +449,6 @@ class VaultViewModel @Inject constructor(
premiumStateManager.dismissPremiumUpgradeBanner()
}
VaultState.ActionCardState.PremiumNeedsAttention -> {
// No-op: The user must address the issue
}
VaultState.ActionCardState.IntroducingArchive -> {
settingsRepository.dismissIntroducingArchiveActionCard()
}
@@ -471,10 +466,6 @@ class VaultViewModel @Inject constructor(
sendEvent(VaultEvent.NavigateToUpgradePremium)
}
VaultState.ActionCardState.PremiumNeedsAttention -> {
sendEvent(VaultEvent.NavigateToUpgradePremium)
}
VaultState.ActionCardState.IntroducingArchive -> {
settingsRepository.dismissIntroducingArchiveActionCard()
sendEvent(
@@ -1282,7 +1273,7 @@ class VaultViewModel @Inject constructor(
action: VaultAction.Internal.PremiumUpgradeBannerEligibilityReceive,
) {
mutableStateFlow.update {
it.copy(premiumCard = action.premiumCard)
it.copy(isPremiumUpgradeBannerEligible = action.isEligible)
}
}
@@ -1756,7 +1747,7 @@ data class VaultState(
val hasShownDecryptionFailureAlert: Boolean,
val restrictItemTypesPolicyOrgIds: List<String>,
val isIntroducingArchiveActionCardDismissed: Boolean,
val premiumCard: PremiumCard = PremiumCard.NONE,
val isPremiumUpgradeBannerEligible: Boolean = false,
val isUpgradedToPremiumCardEligible: Boolean = false,
val isAwaitingKdfSync: Boolean = false,
val validTotpIds: ImmutableSet<String>,
@@ -1770,10 +1761,8 @@ data class VaultState(
get() = (viewState as? ViewState.Content)?.let {
ActionCardState.UpgradedToPremium
.takeIf { isUpgradedToPremiumCardEligible }
?: ActionCardState.UpgradePremium.takeIf { premiumCard == PremiumCard.UPGRADE }
?: ActionCardState.PremiumNeedsAttention.takeIf {
premiumCard == PremiumCard.NEEDS_ATTENTION
}
?: ActionCardState.UpgradePremium
.takeIf { isPremiumUpgradeBannerEligible }
?: ActionCardState.IntroducingArchive.takeIf {
isPremium && !isIntroducingArchiveActionCardDismissed
}
@@ -2180,11 +2169,6 @@ data class VaultState(
*/
data object UpgradePremium : ActionCardState()
/**
* Indicates that the user needs to address an issue with their Premium account.
*/
data object PremiumNeedsAttention : ActionCardState()
/**
* Indicates that the archive feature is ready for use.
*/
@@ -2763,10 +2747,11 @@ sealed class VaultAction {
) : Internal()
/**
* Indicates that the Premium upgrade banner eligibility has been updated.
* Indicates that the Premium upgrade banner eligibility has been
* updated.
*/
data class PremiumUpgradeBannerEligibilityReceive(
val premiumCard: PremiumCard,
val isEligible: Boolean,
) : Internal()
/**

View File

@@ -131,7 +131,6 @@ import com.x8bit.bitwarden.data.auth.repository.util.CookieCallbackResult
import com.x8bit.bitwarden.data.auth.repository.util.DuoCallbackTokenResult
import com.x8bit.bitwarden.data.auth.repository.util.SsoCallbackResult
import com.x8bit.bitwarden.data.auth.repository.util.WebAuthResult
import com.x8bit.bitwarden.data.auth.repository.util.toKdfRequestModel
import com.x8bit.bitwarden.data.auth.repository.util.toRemovedPasswordUserStateJson
import com.x8bit.bitwarden.data.auth.repository.util.toSdkParams
import com.x8bit.bitwarden.data.auth.repository.util.toUserState
@@ -5059,12 +5058,10 @@ class AuthRepositoryTest {
val newPassword = "newPassword"
val newPasswordHash = "newPasswordHash"
val newKey = "newKey"
val kdf = ACCOUNT_1.profile.toKdfRequestModel()
val email = ACCOUNT_1.profile.email
fakeAuthDiskSource.userState = SINGLE_USER_STATE_1
coEvery {
authSdkSource.hashPassword(
email = email,
email = ACCOUNT_1.profile.email,
password = currentPassword,
kdf = ACCOUNT_1.profile.toSdkParams(),
purpose = HashPurpose.SERVER_AUTHORIZATION,
@@ -5084,17 +5081,15 @@ class AuthRepositoryTest {
accountsService.resetPassword(
body = ResetPasswordRequestJson(
currentPasswordHash = currentPasswordHash,
newPasswordHash = newPasswordHash,
passwordHint = null,
kdf = kdf,
salt = email,
masterPasswordAuthenticationHash = newPasswordHash,
masterKeyWrappedUserKey = newKey,
key = newKey,
),
)
} returns Unit.asSuccess()
coEvery {
authSdkSource.hashPassword(
email = email,
email = ACCOUNT_1.profile.email,
password = newPassword,
kdf = ACCOUNT_1.profile.toSdkParams(),
purpose = HashPurpose.LOCAL_AUTHORIZATION,
@@ -5113,7 +5108,7 @@ class AuthRepositoryTest {
)
coVerify {
authSdkSource.hashPassword(
email = email,
email = ACCOUNT_1.profile.email,
password = currentPassword,
kdf = ACCOUNT_1.profile.toSdkParams(),
purpose = HashPurpose.SERVER_AUTHORIZATION,
@@ -5125,14 +5120,16 @@ class AuthRepositoryTest {
accountsService.resetPassword(
body = ResetPasswordRequestJson(
currentPasswordHash = currentPasswordHash,
newPasswordHash = newPasswordHash,
passwordHint = null,
kdf = kdf,
salt = email,
masterPasswordAuthenticationHash = newPasswordHash,
masterKeyWrappedUserKey = newKey,
key = newKey,
),
)
}
fakeAuthDiskSource.assertMasterPasswordHash(
userId = USER_ID_1,
passwordHash = newPasswordHash,
)
verify(exactly = 1) {
toastManager.show(messageId = BitwardenString.updated_master_password)
userLogoutManager.logout(
@@ -5585,7 +5582,7 @@ class AuthRepositoryTest {
encryptedUserKey = encryptedUserKey,
keys = RsaKeyPair(public = publicRsaKey, private = privateRsaKey),
)
val setPasswordRequestJson = SetPasswordRequestJson.V1(
val setPasswordRequestJson = SetPasswordRequestJson(
passwordHash = passwordHash,
passwordHint = passwordHint,
organizationIdentifier = organizationId,
@@ -5594,7 +5591,7 @@ class AuthRepositoryTest {
kdfParallelism = profile.kdfParallelism,
kdfType = profile.kdfType,
key = encryptedUserKey,
keys = SetPasswordRequestJson.V1.Keys(
keys = SetPasswordRequestJson.Keys(
publicKey = publicRsaKey,
encryptedPrivateKey = privateRsaKey,
),
@@ -5643,7 +5640,7 @@ class AuthRepositoryTest {
encryptedUserKey = encryptedUserKey,
keys = RsaKeyPair(public = publicRsaKey, private = privateRsaKey),
)
val setPasswordRequestJson = SetPasswordRequestJson.V1(
val setPasswordRequestJson = SetPasswordRequestJson(
passwordHash = passwordHash,
passwordHint = passwordHint,
organizationIdentifier = organizationIdentifier,
@@ -5652,7 +5649,7 @@ class AuthRepositoryTest {
kdfParallelism = profile.kdfParallelism,
kdfType = profile.kdfType,
key = encryptedUserKey,
keys = SetPasswordRequestJson.V1.Keys(
keys = SetPasswordRequestJson.Keys(
publicKey = publicRsaKey,
encryptedPrivateKey = privateRsaKey,
),
@@ -5752,13 +5749,16 @@ class AuthRepositoryTest {
passwordHash = passwordHash,
newKey = encryptedUserKey,
)
val setPasswordRequestJson = SetPasswordRequestJson.V2(
val setPasswordRequestJson = SetPasswordRequestJson(
passwordHash = passwordHash,
passwordHint = passwordHint,
organizationIdentifier = organizationIdentifier,
kdf = profile.toKdfRequestModel(),
salt = EMAIL,
masterPasswordAuthenticationHash = passwordHash,
masterKeyWrappedUserKey = encryptedUserKey,
kdfIterations = profile.kdfIterations,
kdfMemory = profile.kdfMemory,
kdfParallelism = profile.kdfParallelism,
kdfType = profile.kdfType,
key = encryptedUserKey,
keys = null,
)
fakeAuthDiskSource.userState = userState
coEvery {
@@ -5795,6 +5795,9 @@ class AuthRepositoryTest {
userId = profile.userId,
)
} returns resetPasswordKey.asSuccess()
coEvery {
vaultRepository.unlockVaultWithMasterPassword(password)
} returns VaultUnlockResult.Success
val result = repository.setPassword(
organizationIdentifier = organizationIdentifier,
@@ -5819,6 +5822,7 @@ class AuthRepositoryTest {
passwordHash = passwordHash,
resetPasswordKey = resetPasswordKey,
)
vaultRepository.unlockVaultWithMasterPassword(password)
vaultSdkSource.getResetPasswordKey(
orgPublicKey = publicOrgKey,
userId = profile.userId,
@@ -5849,7 +5853,7 @@ class AuthRepositoryTest {
encryptedUserKey = encryptedUserKey,
keys = RsaKeyPair(public = publicRsaKey, private = privateRsaKey),
)
val setPasswordRequestJson = SetPasswordRequestJson.V1(
val setPasswordRequestJson = SetPasswordRequestJson(
passwordHash = passwordHash,
passwordHint = passwordHint,
organizationIdentifier = organizationIdentifier,
@@ -5858,7 +5862,7 @@ class AuthRepositoryTest {
kdfParallelism = profile.kdfParallelism,
kdfType = profile.kdfType,
key = encryptedUserKey,
keys = SetPasswordRequestJson.V1.Keys(
keys = SetPasswordRequestJson.Keys(
publicKey = publicRsaKey,
encryptedPrivateKey = privateRsaKey,
),
@@ -7477,7 +7481,7 @@ class AuthRepositoryTest {
masterPasswordUnlock = MasterPasswordUnlockDataJson(
kdf = BASE_PROFILE_1.toSdkParams().toKdfRequestModel(),
masterKeyWrappedUserKey = ENCRYPTED_USER_KEY,
salt = EMAIL,
salt = "mockSalt",
),
),
)
@@ -7529,7 +7533,7 @@ class AuthRepositoryTest {
masterPasswordUnlock = MasterPasswordUnlockDataJson(
kdf = BASE_PROFILE_1.toSdkParams().toKdfRequestModel(),
masterKeyWrappedUserKey = ENCRYPTED_USER_KEY,
salt = EMAIL,
salt = "mockSalt",
),
),
),

View File

@@ -9,13 +9,10 @@ import com.bitwarden.network.model.KdfTypeJson
import com.bitwarden.network.model.KeyConnectorUserDecryptionOptionsJson
import com.bitwarden.network.model.MasterPasswordUnlockDataJson
import com.bitwarden.network.model.OrganizationType
import com.bitwarden.network.model.SyncResponseJson
import com.bitwarden.network.model.TrustedDeviceUserDecryptionOptionsJson
import com.bitwarden.network.model.UserDecryptionJson
import com.bitwarden.network.model.UserDecryptionOptionsJson
import com.bitwarden.network.model.createMockOrganizationNetwork
import com.bitwarden.network.model.createMockPermissions
import com.bitwarden.network.model.createMockProfile
import com.bitwarden.network.model.createMockSyncResponse
import com.bitwarden.policies.PolicyType
import com.x8bit.bitwarden.data.auth.datasource.disk.model.AccountJson
import com.x8bit.bitwarden.data.auth.datasource.disk.model.AccountTokensJson
@@ -237,36 +234,36 @@ class UserStateJsonExtensionsTest {
)
val orgOnlyResult = originalState.toUpdatedUserStateJson(
syncResponse = createMockSyncResponse(
number = 1,
profile = createMockProfile(
number = 1,
id = "activeUserId",
avatarColor = "color",
securityStamp = "stamp",
isPremium = false,
isPremiumFromOrganization = true,
),
userDecryption = null,
),
syncResponse = mockk {
every { profile } returns mockk {
every { id } returns "activeUserId"
every { avatarColor } returns "color"
every { securityStamp } returns "stamp"
every { isPremium } returns false
every { isPremiumFromOrganization } returns true
every { isTwoFactorEnabled } returns false
every { creationDate } returns Instant.parse("2024-09-13T01:00:00.00Z")
every { userDecryption } returns null
}
},
)
val orgOnlyProfile = orgOnlyResult.accounts.getValue("activeUserId").profile
assertEquals(false, orgOnlyProfile.hasPremiumPersonally)
assertEquals(true, orgOnlyProfile.hasPremiumFromOrganization)
val personalOnlyResult = originalState.toUpdatedUserStateJson(
syncResponse = createMockSyncResponse(
number = 1,
profile = createMockProfile(
number = 1,
id = "activeUserId",
avatarColor = "color",
securityStamp = "stamp",
isPremium = true,
isPremiumFromOrganization = false,
),
userDecryption = null,
),
syncResponse = mockk {
every { profile } returns mockk {
every { id } returns "activeUserId"
every { avatarColor } returns "color"
every { securityStamp } returns "stamp"
every { isPremium } returns true
every { isPremiumFromOrganization } returns false
every { isTwoFactorEnabled } returns false
every { creationDate } returns Instant.parse("2024-09-13T01:00:00.00Z")
every { userDecryption } returns null
}
},
)
val personalOnlyProfile = personalOnlyResult.accounts.getValue("activeUserId").profile
assertEquals(true, personalOnlyProfile.hasPremiumPersonally)
@@ -401,20 +398,74 @@ class UserStateJsonExtensionsTest {
),
)
.toUpdatedUserStateJson(
syncResponse = createMockSyncResponse(
number = 1,
profile = createMockProfile(
number = 1,
id = "activeUserId",
avatarColor = "avatarColor",
securityStamp = "securityStamp",
isPremium = true,
isPremiumFromOrganization = true,
creationDate = Instant.parse("2024-09-13T01:00:00.00Z"),
syncResponse = mockk {
every { profile } returns mockk {
every { id } returns "activeUserId"
every { avatarColor } returns "avatarColor"
every { securityStamp } returns "securityStamp"
every { isPremium } returns true
every { isPremiumFromOrganization } returns true
every { isTwoFactorEnabled } returns false
every { creationDate } returns Instant.parse("2024-09-13T01:00:00.00Z")
every { userDecryption } returns null
}
},
),
)
}
@Suppress("MaxLineLength")
@Test
fun `toUserStateJsonWithPassword should update active account to set hasMasterPassword and clear forcePasswordResetReason`() {
val originalProfile = AccountJson.Profile(
userId = "activeUserId",
email = "email",
isEmailVerified = true,
name = "name",
stamp = null,
organizationId = null,
avatarColorHex = null,
hasPremiumPersonally = true,
hasPremiumFromOrganization = null,
forcePasswordResetReason = ForcePasswordResetReason
.TDE_USER_WITHOUT_PASSWORD_HAS_PASSWORD_RESET_PERMISSION,
kdfType = KdfTypeJson.ARGON2_ID,
kdfIterations = 600000,
kdfMemory = 16,
kdfParallelism = 4,
userDecryptionOptions = null,
isTwoFactorEnabled = false,
creationDate = Instant.parse("2024-09-13T01:00:00.00Z"),
)
val originalAccount = AccountJson(
profile = originalProfile,
tokens = mockk(),
settings = mockk(),
)
assertEquals(
UserStateJson(
activeUserId = "activeUserId",
accounts = mapOf(
"activeUserId" to originalAccount.copy(
profile = originalProfile.copy(
forcePasswordResetReason = null,
userDecryptionOptions = UserDecryptionOptionsJson(
hasMasterPassword = true,
keyConnectorUserDecryptionOptions = null,
trustedDeviceUserDecryptionOptions = null,
masterPasswordUnlock = null,
),
),
userDecryption = null,
),
),
),
UserStateJson(
activeUserId = "activeUserId",
accounts = mapOf(
"activeUserId" to originalAccount,
),
)
.toUserStateJsonWithPassword(masterPasswordUnlock = null),
)
}
@@ -485,6 +536,71 @@ class UserStateJsonExtensionsTest {
)
}
@Test
fun `toUserStateJsonWithPassword should preserve values of userDecryptionOptions`() {
val keyConnectorOptionsJson = KeyConnectorUserDecryptionOptionsJson("key")
val trustedDeviceOptionsJson = TrustedDeviceUserDecryptionOptionsJson(
encryptedPrivateKey = "encryptedPrivateKey",
encryptedUserKey = "encryptedUserKey",
hasAdminApproval = true,
hasLoginApprovingDevice = true,
hasManageResetPasswordPermission = true,
)
val originalProfile = AccountJson.Profile(
userId = "activeUserId",
email = "email",
isEmailVerified = true,
name = "name",
stamp = null,
organizationId = null,
avatarColorHex = null,
hasPremiumPersonally = true,
hasPremiumFromOrganization = null,
forcePasswordResetReason = null,
kdfType = KdfTypeJson.ARGON2_ID,
kdfIterations = 600000,
kdfMemory = 16,
kdfParallelism = 4,
userDecryptionOptions = UserDecryptionOptionsJson(
hasMasterPassword = true,
keyConnectorUserDecryptionOptions = keyConnectorOptionsJson,
trustedDeviceUserDecryptionOptions = trustedDeviceOptionsJson,
masterPasswordUnlock = null,
),
isTwoFactorEnabled = false,
creationDate = Instant.parse("2024-09-13T01:00:00.00Z"),
)
val originalAccount = AccountJson(
profile = originalProfile,
tokens = mockk(),
settings = mockk(),
)
assertEquals(
UserStateJson(
activeUserId = "activeUserId",
accounts = mapOf(
"activeUserId" to originalAccount.copy(
profile = originalProfile.copy(
userDecryptionOptions = UserDecryptionOptionsJson(
hasMasterPassword = true,
keyConnectorUserDecryptionOptions = keyConnectorOptionsJson,
trustedDeviceUserDecryptionOptions = trustedDeviceOptionsJson,
masterPasswordUnlock = null,
),
),
),
),
),
UserStateJson(
activeUserId = "activeUserId",
accounts = mapOf(
"activeUserId" to originalAccount,
),
)
.toUserStateJsonWithPassword(masterPasswordUnlock = null),
)
}
@Test
fun `toUserState should return the correct UserState for an unlocked vault`() {
val expectedCreationDate = Instant.parse("2024-06-15T10:30:00Z")
@@ -1877,22 +1993,20 @@ class UserStateJsonExtensionsTest {
accounts = mapOf("activeUserId" to originalAccount),
)
val syncResponse = createMockSyncResponse(
number = 1,
profile = createMockProfile(
number = 1,
id = "activeUserId",
avatarColor = "avatarColor",
securityStamp = "securityStamp",
isPremium = false,
isPremiumFromOrganization = false,
isTwoFactorEnabled = true,
creationDate = Instant.parse("2024-09-13T01:00:00.00Z"),
),
userDecryption = UserDecryptionJson(
val syncResponse = mockk<SyncResponseJson>(relaxed = true) {
every { profile } returns mockk {
every { id } returns "activeUserId"
every { avatarColor } returns "avatarColor"
every { securityStamp } returns "securityStamp"
every { isPremium } returns false
every { isPremiumFromOrganization } returns false
every { isTwoFactorEnabled } returns true
every { creationDate } returns Instant.parse("2024-09-13T01:00:00.00Z")
}
every { userDecryption } returns UserDecryptionJson(
masterPasswordUnlock = MOCK_MASTER_PASSWORD_UNLOCK_DATA,
),
)
)
}
assertEquals(
UserStateJson(
@@ -1968,22 +2082,20 @@ class UserStateJsonExtensionsTest {
accounts = mapOf("activeUserId" to originalAccount),
)
val syncResponse = createMockSyncResponse(
number = 1,
profile = createMockProfile(
number = 1,
id = "activeUserId",
avatarColor = "newAvatarColor",
securityStamp = "newSecurityStamp",
isPremium = true,
isPremiumFromOrganization = false,
isTwoFactorEnabled = true,
creationDate = Instant.parse("2024-09-13T01:00:00.00Z"),
),
userDecryption = UserDecryptionJson(
val syncResponse = mockk<SyncResponseJson> {
every { profile } returns mockk {
every { id } returns "activeUserId"
every { avatarColor } returns "newAvatarColor"
every { securityStamp } returns "newSecurityStamp"
every { isPremium } returns true
every { isPremiumFromOrganization } returns false
every { isTwoFactorEnabled } returns true
every { creationDate } returns Instant.parse("2024-09-13T01:00:00.00Z")
}
every { userDecryption } returns UserDecryptionJson(
masterPasswordUnlock = MOCK_MASTER_PASSWORD_UNLOCK_DATA,
),
)
)
}
assertEquals(
UserStateJson(
@@ -2017,7 +2129,7 @@ class UserStateJsonExtensionsTest {
@Test
@Suppress("MaxLineLength")
fun `toUpdatedUserStateJson should clear hasMasterPassword and masterPasswordUnlock when syncResponse has no userDecryption`() {
fun `toUpdatedUserStateJson should update existing UserDecryptionOptionsJson when syncResponse has no userDecryption`() {
val keyConnectorOptions = KeyConnectorUserDecryptionOptionsJson("keyConnectorUrl")
val originalProfile = AccountJson.Profile(
userId = "activeUserId",
@@ -2053,20 +2165,18 @@ class UserStateJsonExtensionsTest {
accounts = mapOf("activeUserId" to originalAccount),
)
val syncResponse = createMockSyncResponse(
number = 1,
profile = createMockProfile(
number = 1,
id = "activeUserId",
avatarColor = "updatedAvatarColor",
securityStamp = "updatedSecurityStamp",
isPremium = false,
isPremiumFromOrganization = true,
creationDate = Instant.parse("2024-09-13T01:00:00.00Z"),
organizations = emptyList(),
),
userDecryption = null,
)
val syncResponse = mockk<SyncResponseJson> {
every { profile } returns mockk {
every { id } returns "activeUserId"
every { avatarColor } returns "updatedAvatarColor"
every { securityStamp } returns "updatedSecurityStamp"
every { isPremium } returns false
every { isPremiumFromOrganization } returns true
every { isTwoFactorEnabled } returns false
every { creationDate } returns Instant.parse("2024-09-13T01:00:00.00Z")
}
every { userDecryption } returns null
}
assertEquals(
UserStateJson(
@@ -2081,7 +2191,7 @@ class UserStateJsonExtensionsTest {
isTwoFactorEnabled = false,
creationDate = Instant.parse("2024-09-13T01:00:00.00Z"),
userDecryptionOptions = UserDecryptionOptionsJson(
hasMasterPassword = false,
hasMasterPassword = true,
trustedDeviceUserDecryptionOptions = null,
keyConnectorUserDecryptionOptions = keyConnectorOptions,
masterPasswordUnlock = null,
@@ -2126,28 +2236,31 @@ class UserStateJsonExtensionsTest {
accounts = mapOf("activeUserId" to originalAccount),
)
val syncResponse = createMockSyncResponse(
number = 1,
profile = createMockProfile(
number = 1,
id = "activeUserId",
avatarColor = null,
securityStamp = null,
creationDate = Instant.parse("2024-09-13T01:00:00.00Z"),
),
userDecryption = UserDecryptionJson(
masterPasswordUnlock = MasterPasswordUnlockDataJson(
salt = "mockSalt",
kdf = KdfJson(
kdfType = KdfTypeJson.PBKDF2_SHA256,
iterations = DEFAULT_PBKDF2_ITERATIONS,
memory = null,
parallelism = null,
),
masterKeyWrappedUserKey = "mockMasterKeyWrappedUserKey",
),
),
)
val syncResponse = mockk<SyncResponseJson> {
every { profile } returns mockk {
every { id } returns "activeUserId"
every { avatarColor } returns null
every { securityStamp } returns null
every { isPremium } returns false
every { isPremiumFromOrganization } returns false
every { isTwoFactorEnabled } returns false
every { creationDate } returns Instant.parse("2024-09-13T01:00:00.00Z")
}
val updatedKdf = KdfJson(
kdfType = KdfTypeJson.PBKDF2_SHA256,
iterations = DEFAULT_PBKDF2_ITERATIONS,
memory = null,
parallelism = null,
)
val updatedMasterPasswordUnlock = MasterPasswordUnlockDataJson(
salt = "mockSalt",
kdf = updatedKdf,
masterKeyWrappedUserKey = "mockMasterKeyWrappedUserKey",
)
every { userDecryption } returns UserDecryptionJson(
masterPasswordUnlock = updatedMasterPasswordUnlock,
)
}
assertEquals(
UserStateJson(
@@ -2180,137 +2293,6 @@ class UserStateJsonExtensionsTest {
)
}
@Test
@Suppress("MaxLineLength")
fun `toUpdatedUserStateJson should set forcePasswordResetReason when user without master password is an organization admin`() {
val originalUserState = createUserStateWithDecryptionOptions(
userDecryptionOptions = TDE_USER_DECRYPTION_OPTIONS,
)
val result = originalUserState.toUpdatedUserStateJson(
syncResponse = createMockSyncResponse(
number = 1,
profile = createMockProfile(
number = 1,
id = "activeUserId",
organizations = listOf(
createMockOrganizationNetwork(
number = 1,
type = OrganizationType.ADMIN,
),
),
),
userDecryption = null,
),
)
assertEquals(
ForcePasswordResetReason.TDE_USER_WITHOUT_PASSWORD_HAS_PASSWORD_RESET_PERMISSION,
result.accounts.getValue("activeUserId").profile.forcePasswordResetReason,
)
}
@Test
@Suppress("MaxLineLength")
fun `toUpdatedUserStateJson should set forcePasswordResetReason when user without master password has reset password permission`() {
val originalUserState = createUserStateWithDecryptionOptions(
userDecryptionOptions = TDE_USER_DECRYPTION_OPTIONS,
)
val result = originalUserState.toUpdatedUserStateJson(
syncResponse = createMockSyncResponse(
number = 1,
profile = createMockProfile(
number = 1,
id = "activeUserId",
organizations = listOf(
createMockOrganizationNetwork(
number = 1,
type = OrganizationType.USER,
permissions = createMockPermissions(
shouldManageResetPassword = true,
),
),
),
),
userDecryption = UserDecryptionJson(masterPasswordUnlock = null),
),
)
assertEquals(
ForcePasswordResetReason.TDE_USER_WITHOUT_PASSWORD_HAS_PASSWORD_RESET_PERMISSION,
result.accounts.getValue("activeUserId").profile.forcePasswordResetReason,
)
}
@Test
@Suppress("MaxLineLength")
fun `toUpdatedUserStateJson should not set forcePasswordResetReason when user without master password lacks reset password permission`() {
val originalUserState = createUserStateWithDecryptionOptions(
userDecryptionOptions = TDE_USER_DECRYPTION_OPTIONS,
)
val result = originalUserState.toUpdatedUserStateJson(
syncResponse = createMockSyncResponse(
number = 1,
profile = createMockProfile(
number = 1,
id = "activeUserId",
organizations = listOf(
createMockOrganizationNetwork(
number = 1,
type = OrganizationType.USER,
),
),
),
userDecryption = null,
),
)
assertEquals(
null,
result.accounts.getValue("activeUserId").profile.forcePasswordResetReason,
)
}
@Test
@Suppress("MaxLineLength")
fun `toUpdatedUserStateJson should preserve previous forcePasswordResetReason when user has a master password`() {
val originalUserState = createUserStateWithDecryptionOptions(
userDecryptionOptions = UserDecryptionOptionsJson(
hasMasterPassword = true,
trustedDeviceUserDecryptionOptions = null,
keyConnectorUserDecryptionOptions = null,
masterPasswordUnlock = null,
),
forcePasswordResetReason = ForcePasswordResetReason.WEAK_MASTER_PASSWORD_ON_LOGIN,
)
val result = originalUserState.toUpdatedUserStateJson(
syncResponse = createMockSyncResponse(
number = 1,
profile = createMockProfile(
number = 1,
id = "activeUserId",
organizations = listOf(
createMockOrganizationNetwork(
number = 1,
type = OrganizationType.OWNER,
),
),
),
userDecryption = UserDecryptionJson(
masterPasswordUnlock = MOCK_MASTER_PASSWORD_UNLOCK_DATA,
),
),
)
assertEquals(
ForcePasswordResetReason.WEAK_MASTER_PASSWORD_ON_LOGIN,
result.accounts.getValue("activeUserId").profile.forcePasswordResetReason,
)
}
@Test
fun `toUserStateJsonKdfUpdatedMinimums should update KDF settings to minimum values`() {
val originalProfile = AccountJson.Profile(
@@ -2512,53 +2494,3 @@ private val MOCK_MASTER_PASSWORD_UNLOCK_DATA = MasterPasswordUnlockDataJson(
),
masterKeyWrappedUserKey = "masterKeyWrappedUserKeyMock",
)
private val TDE_USER_DECRYPTION_OPTIONS = UserDecryptionOptionsJson(
hasMasterPassword = false,
trustedDeviceUserDecryptionOptions = TrustedDeviceUserDecryptionOptionsJson(
encryptedPrivateKey = "encryptedPrivateKey",
encryptedUserKey = "encryptedUserKey",
hasAdminApproval = true,
hasLoginApprovingDevice = false,
hasManageResetPasswordPermission = false,
),
keyConnectorUserDecryptionOptions = null,
masterPasswordUnlock = null,
)
/**
* Creates a [UserStateJson] with a single "activeUserId" account using the given
* [userDecryptionOptions] and [forcePasswordResetReason].
*/
private fun createUserStateWithDecryptionOptions(
userDecryptionOptions: UserDecryptionOptionsJson?,
forcePasswordResetReason: ForcePasswordResetReason? = null,
): UserStateJson =
UserStateJson(
activeUserId = "activeUserId",
accounts = mapOf(
"activeUserId" to AccountJson(
profile = AccountJson.Profile(
userId = "activeUserId",
email = "email",
isEmailVerified = true,
name = "name",
stamp = null,
organizationId = null,
avatarColorHex = null,
hasPremiumPersonally = true,
hasPremiumFromOrganization = null,
forcePasswordResetReason = forcePasswordResetReason,
kdfType = KdfTypeJson.ARGON2_ID,
kdfIterations = 600000,
kdfMemory = 16,
kdfParallelism = 4,
userDecryptionOptions = userDecryptionOptions,
isTwoFactorEnabled = false,
creationDate = Instant.parse("2024-09-13T01:00:00.00Z"),
),
tokens = null,
settings = AccountJson.Settings(environmentUrlData = null),
),
),
)

View File

@@ -10,7 +10,6 @@ import com.bitwarden.vault.DecryptCipherListResult
import com.x8bit.bitwarden.data.auth.datasource.disk.model.AccountJson
import com.x8bit.bitwarden.data.auth.datasource.disk.model.UserStateJson
import com.x8bit.bitwarden.data.auth.datasource.disk.util.FakeAuthDiskSource
import com.x8bit.bitwarden.data.billing.model.PremiumCard
import com.x8bit.bitwarden.data.billing.repository.BillingRepository
import com.x8bit.bitwarden.data.billing.repository.model.PlanCadence
import com.x8bit.bitwarden.data.billing.repository.model.PremiumSubscriptionStatus
@@ -109,160 +108,160 @@ class PremiumStateManagerTest {
)
@Test
fun `eligible when all conditions met should emit UPGRADE`() = runTest {
fun `eligible when all conditions met should emit true`() = runTest {
val manager = createManager()
manager.premiumCardStateFlow.test {
assertEquals(PremiumCard.UPGRADE, awaitItem())
manager.isPremiumUpgradeBannerEligibleFlow.test {
assertTrue(awaitItem())
}
}
@Test
fun `ineligible when user is Premium should emit NONE`() = runTest {
fun `ineligible when user is Premium should emit false`() = runTest {
fakeAuthDiskSource.userState = userStateJsonWith(
account = createAccountJson(hasPremiumPersonally = true),
)
val manager = createManager()
manager.premiumCardStateFlow.test {
assertEquals(PremiumCard.NONE, awaitItem())
manager.isPremiumUpgradeBannerEligibleFlow.test {
assertFalse(awaitItem())
}
}
@Test
fun `ineligible when in-app billing is not supported should emit NONE`() =
fun `ineligible when in-app billing is not supported should emit false`() =
runTest {
mutableIsInAppBillingSupportedFlow.value = false
val manager = createManager()
manager.premiumCardStateFlow.test {
assertEquals(PremiumCard.NONE, awaitItem())
manager.isPremiumUpgradeBannerEligibleFlow.test {
assertFalse(awaitItem())
}
}
@Test
fun `ineligible when feature flag is disabled should emit NONE`() = runTest {
fun `ineligible when feature flag is disabled should emit false`() = runTest {
mutableMobilePremiumUpgradeFlagFlow.value = false
val manager = createManager()
manager.premiumCardStateFlow.test {
assertEquals(PremiumCard.NONE, awaitItem())
manager.isPremiumUpgradeBannerEligibleFlow.test {
assertFalse(awaitItem())
}
}
@Test
fun `ineligible when banner is dismissed should emit NONE`() = runTest {
fun `ineligible when banner is dismissed should emit false`() = runTest {
fakeSettingsDiskSource.storePremiumUpgradeBannerDismissed(
userId = ACTIVE_USER_ID,
isDismissed = true,
)
val manager = createManager()
manager.premiumCardStateFlow.test {
assertEquals(PremiumCard.NONE, awaitItem())
manager.isPremiumUpgradeBannerEligibleFlow.test {
assertFalse(awaitItem())
}
}
@Test
fun `ineligible when account is too new should emit NONE`() = runTest {
fun `ineligible when account is too new should emit false`() = runTest {
fakeAuthDiskSource.userState = userStateJsonWith(
account = createAccountJson(
creationDate = Instant.parse("2023-10-25T12:00:00Z"),
),
)
val manager = createManager()
manager.premiumCardStateFlow.test {
assertEquals(PremiumCard.NONE, awaitItem())
manager.isPremiumUpgradeBannerEligibleFlow.test {
assertFalse(awaitItem())
}
}
@Test
fun `ineligible when creation date is null should emit NONE`() = runTest {
fun `ineligible when creation date is null should emit false`() = runTest {
fakeAuthDiskSource.userState = userStateJsonWith(
account = createAccountJson(creationDate = null),
)
val manager = createManager()
manager.premiumCardStateFlow.test {
assertEquals(PremiumCard.NONE, awaitItem())
manager.isPremiumUpgradeBannerEligibleFlow.test {
assertFalse(awaitItem())
}
}
@Test
fun `ineligible when vault has fewer than 5 items should emit NONE`() = runTest {
fun `ineligible when vault has fewer than 5 items should emit false`() = runTest {
mutableVaultDataStateFlow.value = DataState.Loaded(
createVaultDataWithItemCount(count = 4),
)
val manager = createManager()
manager.premiumCardStateFlow.test {
assertEquals(PremiumCard.NONE, awaitItem())
manager.isPremiumUpgradeBannerEligibleFlow.test {
assertFalse(awaitItem())
}
}
@Test
fun `ineligible when userState is null should emit NONE`() = runTest {
fun `ineligible when userState is null should emit false`() = runTest {
fakeAuthDiskSource.userState = null
val manager = createManager()
manager.premiumCardStateFlow.test {
assertEquals(PremiumCard.NONE, awaitItem())
manager.isPremiumUpgradeBannerEligibleFlow.test {
assertFalse(awaitItem())
}
}
@Test
fun `vault data Loading should emit NONE`() = runTest {
fun `vault data Loading should emit false`() = runTest {
mutableVaultDataStateFlow.value = DataState.Loading
val manager = createManager()
manager.premiumCardStateFlow.test {
assertEquals(PremiumCard.NONE, awaitItem())
manager.isPremiumUpgradeBannerEligibleFlow.test {
assertFalse(awaitItem())
}
}
@Test
fun `vault data Pending with enough items should emit UPGRADE`() = runTest {
fun `vault data Pending with enough items should emit true`() = runTest {
mutableVaultDataStateFlow.value = DataState.Pending(
createVaultDataWithItemCount(count = 5),
)
val manager = createManager()
manager.premiumCardStateFlow.test {
assertEquals(PremiumCard.UPGRADE, awaitItem())
manager.isPremiumUpgradeBannerEligibleFlow.test {
assertTrue(awaitItem())
}
}
@Test
fun `vault data NoNetwork with enough items should emit UPGRADE`() = runTest {
fun `vault data NoNetwork with enough items should emit true`() = runTest {
mutableVaultDataStateFlow.value = DataState.NoNetwork(
createVaultDataWithItemCount(count = 5),
)
val manager = createManager()
manager.premiumCardStateFlow.test {
assertEquals(PremiumCard.UPGRADE, awaitItem())
manager.isPremiumUpgradeBannerEligibleFlow.test {
assertTrue(awaitItem())
}
}
@Test
fun `vault data Error with enough items should emit UPGRADE`() = runTest {
fun `vault data Error with enough items should emit true`() = runTest {
mutableVaultDataStateFlow.value = DataState.Error(
error = IllegalStateException("test"),
data = createVaultDataWithItemCount(count = 5),
)
val manager = createManager()
manager.premiumCardStateFlow.test {
assertEquals(PremiumCard.UPGRADE, awaitItem())
manager.isPremiumUpgradeBannerEligibleFlow.test {
assertTrue(awaitItem())
}
}
@Test
fun `vault data NoNetwork without data should emit NONE`() = runTest {
fun `vault data NoNetwork without data should emit false`() = runTest {
mutableVaultDataStateFlow.value = DataState.NoNetwork(data = null)
val manager = createManager()
manager.premiumCardStateFlow.test {
assertEquals(PremiumCard.NONE, awaitItem())
manager.isPremiumUpgradeBannerEligibleFlow.test {
assertFalse(awaitItem())
}
}
@Test
fun `vault data Error without data should emit NONE`() = runTest {
fun `vault data Error without data should emit false`() = runTest {
mutableVaultDataStateFlow.value = DataState.Error(
error = IllegalStateException("test"),
data = null,
)
val manager = createManager()
manager.premiumCardStateFlow.test {
assertEquals(PremiumCard.NONE, awaitItem())
manager.isPremiumUpgradeBannerEligibleFlow.test {
assertFalse(awaitItem())
}
}
@@ -284,8 +283,8 @@ class PremiumStateManagerTest {
),
)
val manager = createManager()
manager.premiumCardStateFlow.test {
assertEquals(PremiumCard.NONE, awaitItem())
manager.isPremiumUpgradeBannerEligibleFlow.test {
assertFalse(awaitItem())
}
}
@@ -307,13 +306,13 @@ class PremiumStateManagerTest {
),
)
val manager = createManager()
manager.premiumCardStateFlow.test {
assertEquals(PremiumCard.NONE, awaitItem())
manager.isPremiumUpgradeBannerEligibleFlow.test {
assertFalse(awaitItem())
}
}
@Test
fun `eligible when account age is exactly 7 days should emit UPGRADE`() =
fun `eligible when account age is exactly 7 days should emit true`() =
runTest {
fakeAuthDiskSource.userState = userStateJsonWith(
account = createAccountJson(
@@ -321,33 +320,33 @@ class PremiumStateManagerTest {
),
)
val manager = createManager()
manager.premiumCardStateFlow.test {
assertEquals(PremiumCard.UPGRADE, awaitItem())
manager.isPremiumUpgradeBannerEligibleFlow.test {
assertTrue(awaitItem())
}
}
@Test
fun `eligible when vault items exactly 5 should emit UPGRADE`() = runTest {
fun `eligible when vault items exactly 5 should emit true`() = runTest {
mutableVaultDataStateFlow.value = DataState.Loaded(
createVaultDataWithItemCount(count = 5),
)
val manager = createManager()
manager.premiumCardStateFlow.test {
assertEquals(PremiumCard.UPGRADE, awaitItem())
manager.isPremiumUpgradeBannerEligibleFlow.test {
assertTrue(awaitItem())
}
}
@Test
fun `eligibility should update when upstream flows change`() = runTest {
val manager = createManager()
manager.premiumCardStateFlow.test {
assertEquals(PremiumCard.UPGRADE, awaitItem())
manager.isPremiumUpgradeBannerEligibleFlow.test {
assertTrue(awaitItem())
mutableIsInAppBillingSupportedFlow.value = false
assertEquals(PremiumCard.NONE, awaitItem())
assertFalse(awaitItem())
mutableIsInAppBillingSupportedFlow.value = true
assertEquals(PremiumCard.UPGRADE, awaitItem())
assertTrue(awaitItem())
}
}
@@ -834,14 +833,14 @@ class PremiumStateManagerTest {
isPending = true,
)
val manager = createManager()
manager.premiumCardStateFlow.test {
assertEquals(PremiumCard.NONE, awaitItem())
manager.isPremiumUpgradeBannerEligibleFlow.test {
assertFalse(awaitItem())
// Clearing pending re-enables the banner.
fakeSettingsDiskSource.storePremiumUpgradePending(
userId = ACTIVE_USER_ID,
isPending = false,
)
assertEquals(PremiumCard.UPGRADE, awaitItem())
assertTrue(awaitItem())
}
}
@@ -1027,40 +1026,18 @@ class PremiumStateManagerTest {
subscription = createSubscriptionInfo(status = PremiumSubscriptionStatus.ACTIVE),
)
val manager = createManager()
manager.premiumCardStateFlow.test {
assertEquals(PremiumCard.NONE, awaitItem())
manager.isPremiumUpgradeBannerEligibleFlow.test {
assertFalse(awaitItem())
}
}
@Test
fun `premium account with an EXPIRED or PAUSED status should emit UPGRADE`() = runTest {
fun `banner eligible when account is premium but status is in a trouble state`() = runTest {
listOf(
PremiumSubscriptionStatus.CANCELED,
PremiumSubscriptionStatus.EXPIRED,
PremiumSubscriptionStatus.PAUSED,
).forEach { status ->
fakeAuthDiskSource.userState = userStateJsonWith(
account = createAccountJson(hasPremiumPersonally = true),
)
coEvery {
billingRepository.getSubscription()
} returns SubscriptionResult.Success(
subscription = createSubscriptionInfo(status = status),
)
val manager = createManager()
manager.premiumCardStateFlow.test {
assertEquals(
PremiumCard.UPGRADE,
awaitItem(),
"Expected UPGRADE for status=$status",
)
}
}
}
@Test
fun `premium account with a payment-trouble status should emit NEEDS_ATTENTION`() = runTest {
listOf(
PremiumSubscriptionStatus.PAST_DUE,
PremiumSubscriptionStatus.PAUSED,
PremiumSubscriptionStatus.UPDATE_PAYMENT,
).forEach { status ->
fakeAuthDiskSource.userState = userStateJsonWith(
@@ -1072,70 +1049,12 @@ class PremiumStateManagerTest {
subscription = createSubscriptionInfo(status = status),
)
val manager = createManager()
manager.premiumCardStateFlow.test {
assertEquals(
PremiumCard.NEEDS_ATTENTION,
awaitItem(),
"Expected NEEDS_ATTENTION for status=$status",
)
manager.isPremiumUpgradeBannerEligibleFlow.test {
assertTrue(awaitItem(), "Expected banner eligible for status=$status")
}
}
}
@Suppress("MaxLineLength")
@Test
fun `premium account with a payment-trouble status emits NEEDS_ATTENTION when billing unsupported`() =
runTest {
// The NEEDS_ATTENTION card is not gated on in-app billing support (unlike the UPGRADE
// card) — the user must resolve the payment issue regardless of platform billing.
mutableIsInAppBillingSupportedFlow.value = false
listOf(
PremiumSubscriptionStatus.PAST_DUE,
PremiumSubscriptionStatus.UPDATE_PAYMENT,
).forEach { status ->
fakeAuthDiskSource.userState = userStateJsonWith(
account = createAccountJson(hasPremiumPersonally = true),
)
coEvery {
billingRepository.getSubscription()
} returns SubscriptionResult.Success(
subscription = createSubscriptionInfo(status = status),
)
val manager = createManager()
manager.premiumCardStateFlow.test {
assertEquals(
PremiumCard.NEEDS_ATTENTION,
awaitItem(),
"Expected NEEDS_ATTENTION for status=$status",
)
}
}
}
@Suppress("MaxLineLength")
@Test
fun `premium account with an ACTIVE, CANCELED, or PENDING_CANCELLATION status should emit NONE`() =
runTest {
listOf(
PremiumSubscriptionStatus.ACTIVE,
PremiumSubscriptionStatus.CANCELED,
PremiumSubscriptionStatus.PENDING_CANCELLATION,
).forEach { status ->
fakeAuthDiskSource.userState = userStateJsonWith(
account = createAccountJson(hasPremiumPersonally = true),
)
coEvery {
billingRepository.getSubscription()
} returns SubscriptionResult.Success(
subscription = createSubscriptionInfo(status = status),
)
val manager = createManager()
manager.premiumCardStateFlow.test {
assertEquals(PremiumCard.NONE, awaitItem(), "Expected NONE for status=$status")
}
}
}
@Test
fun `banner ineligible when account is premium and substate is still loading`() = runTest {
fakeAuthDiskSource.userState = userStateJsonWith(
@@ -1147,10 +1066,10 @@ class PremiumStateManagerTest {
kotlinx.coroutines.awaitCancellation()
}
val manager = createManager()
manager.premiumCardStateFlow.test {
manager.isPremiumUpgradeBannerEligibleFlow.test {
// Loading is not treated as a trouble state so a premium user is still effectively
// premium during the initial fetch.
assertEquals(PremiumCard.NONE, awaitItem())
assertFalse(awaitItem())
}
}
}

View File

@@ -4,7 +4,6 @@ import android.view.autofill.AutofillManager
import app.cash.turbine.test
import com.bitwarden.authenticatorbridge.util.generateSecretKey
import com.bitwarden.core.EnrollPinResponse
import com.bitwarden.core.data.manager.BuildInfoManager
import com.bitwarden.core.data.manager.dispatcher.FakeDispatcherManager
import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow
import com.bitwarden.core.data.util.asFailure
@@ -76,9 +75,6 @@ class SettingsRepositoryTest {
} returns mutableActivePolicyFlow
}
private val flightRecorderManager = mockk<FlightRecorderManager>()
private val buildInfoManager: BuildInfoManager = mockk {
every { isFdroid } returns false
}
private val settingsRepository = SettingsRepositoryImpl(
autofillManager = autofillManager,
@@ -90,7 +86,6 @@ class SettingsRepositoryTest {
dispatcherManager = FakeDispatcherManager(),
policyManager = policyManager,
flightRecorderManager = flightRecorderManager,
buildInfoManager = buildInfoManager,
)
@BeforeEach
@@ -1124,9 +1119,8 @@ class SettingsRepositoryTest {
}
}
@Suppress("MaxLineLength")
@Test
fun `hasShownAccessibilityDisclaimerFlow should emit changes from SettingsDiskSource when fdroid is false`() =
fun `hasShownAccessibilityDisclaimerFlow should emit changes from SettingsDiskSource`() =
runTest {
fakeSettingsDiskSource.hasShownAccessibilityDisclaimer = null
settingsRepository.hasShownAccessibilityDisclaimerFlow.test {
@@ -1140,23 +1134,6 @@ class SettingsRepositoryTest {
}
}
@Suppress("MaxLineLength")
@Test
fun `hasShownAccessibilityDisclaimerFlow should emit changes from SettingsDiskSource when fdroid is true`() =
runTest {
every { buildInfoManager.isFdroid } returns true
fakeSettingsDiskSource.hasShownAccessibilityDisclaimer = null
settingsRepository.hasShownAccessibilityDisclaimerFlow.test {
assertTrue(awaitItem())
fakeSettingsDiskSource.hasShownAccessibilityDisclaimer = true
expectNoEvents()
fakeSettingsDiskSource.hasShownAccessibilityDisclaimer = false
expectNoEvents()
}
}
@Test
fun `accessibilityDisclaimerHasBeenShown should update SettingsDiskSource`() {
assertNull(fakeSettingsDiskSource.hasShownAccessibilityDisclaimer)

View File

@@ -1079,7 +1079,7 @@ class SearchScreenTest : BitwardenComposeTest() {
}
composeTestRule
.onNodeWithText(text = "Premium subscription required")
.onNodeWithText(text = "Archive unavailable")
.assert(hasAnyAncestor(isDialog()))
.assertIsDisplayed()
composeTestRule

View File

@@ -161,7 +161,12 @@ class SendViewModelTest : BaseViewModelTest() {
val viewModel = createViewModel(state = state)
viewModel.trySendAction(SendAction.AddSendSelected(sendType = SendItemType.FILE))
assertEquals(
state.copy(dialogState = SendState.DialogState.FileTypeRequiresPremium),
state.copy(
dialogState = SendState.DialogState.Error(
title = BitwardenString.send.asText(),
message = BitwardenString.send_file_premium_required.asText(),
),
),
viewModel.stateFlow.value,
)
}

View File

@@ -26,7 +26,6 @@ import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow
import com.bitwarden.ui.platform.components.snackbar.model.BitwardenSnackbarData
import com.bitwarden.ui.platform.manager.IntentManager
import com.bitwarden.ui.platform.manager.exit.ExitManager
import com.bitwarden.ui.platform.resource.BitwardenString
import com.bitwarden.ui.util.asText
import com.bitwarden.ui.util.assertNoDialogExists
import com.bitwarden.ui.util.isEditableText
@@ -924,11 +923,7 @@ class AddEditSendScreenTest : BitwardenComposeTest() {
mutableStateFlow.update {
it.copy(
dialogState = AddEditSendState.DialogState.PremiumRequired(
message = BitwardenString
.sharing_with_specific_people_is_a_premium_feature
.asText(),
),
dialogState = AddEditSendState.DialogState.EmailAuthRequiresPremium,
)
}
@@ -949,11 +944,7 @@ class AddEditSendScreenTest : BitwardenComposeTest() {
fun `EmailAuthRequiresPremium dialog Cancel click should send DismissDialogClick`() {
mutableStateFlow.update {
it.copy(
dialogState = AddEditSendState.DialogState.PremiumRequired(
message = BitwardenString
.sharing_with_specific_people_is_a_premium_feature
.asText(),
),
dialogState = AddEditSendState.DialogState.EmailAuthRequiresPremium,
)
}
@@ -968,11 +959,7 @@ class AddEditSendScreenTest : BitwardenComposeTest() {
fun `EmailAuthRequiresPremium dialog Upgrade click should send UpgradeToPremiumClick`() {
mutableStateFlow.update {
it.copy(
dialogState = AddEditSendState.DialogState.PremiumRequired(
message = BitwardenString
.sharing_with_specific_people_is_a_premium_feature
.asText(),
),
dialogState = AddEditSendState.DialogState.EmailAuthRequiresPremium,
)
}

View File

@@ -491,7 +491,8 @@ class AddEditSendViewModelTest : BaseViewModelTest() {
assertEquals(
initialState.copy(
dialogState = AddEditSendState.DialogState.PremiumRequired(
dialogState = AddEditSendState.DialogState.Error(
title = BitwardenString.send.asText(),
message = BitwardenString.send_file_premium_required.asText(),
),
),
@@ -1387,11 +1388,7 @@ class AddEditSendViewModelTest : BaseViewModelTest() {
val newState = awaitItem()
assertEquals(
nonPremiumState.copy(
dialogState = AddEditSendState.DialogState.PremiumRequired(
message = BitwardenString
.sharing_with_specific_people_is_a_premium_feature
.asText(),
),
dialogState = AddEditSendState.DialogState.EmailAuthRequiresPremium,
),
newState,
)

View File

@@ -314,7 +314,7 @@ class VaultAddEditScreenTest : BitwardenComposeTest() {
)
composeTestRule
.onNodeWithText(text = "Premium subscription required")
.onNodeWithText(text = "Archive unavailable")
.assert(hasAnyAncestor(isDialog()))
.assertIsDisplayed()
}

View File

@@ -262,7 +262,7 @@ class AttachmentsScreenTest : BitwardenComposeTest() {
@Test
fun `requires Premium dialog should be displayed according to state`() {
val requiresPremiumMessage = "Premium subscription required"
val requiresPremiumMessage = "Attachments unavailable"
composeTestRule.onNode(isDialog()).assertDoesNotExist()
composeTestRule.onNodeWithText(requiresPremiumMessage).assertDoesNotExist()

View File

@@ -275,7 +275,7 @@ class VaultItemScreenTest : BitwardenComposeTest() {
}
composeTestRule
.onNodeWithText(text = "Premium subscription required")
.onNodeWithText(text = "Archive unavailable")
.assert(hasAnyAncestor(isDialog()))
.assertIsDisplayed()
}

View File

@@ -2579,7 +2579,7 @@ class VaultItemListingScreenTest : BitwardenComposeTest() {
}
composeTestRule
.onNodeWithText(text = "Premium subscription required")
.onNodeWithText(text = "Archive unavailable")
.assert(hasAnyAncestor(isDialog()))
.assertIsDisplayed()
composeTestRule

View File

@@ -48,7 +48,6 @@ import com.bitwarden.ui.util.performLogoutAccountClick
import com.bitwarden.ui.util.performRemoveAccountClick
import com.bitwarden.ui.util.performYesDialogButtonClick
import com.bitwarden.vault.CipherType
import com.x8bit.bitwarden.data.billing.model.PremiumCard
import com.x8bit.bitwarden.ui.platform.base.BitwardenComposeTest
import com.x8bit.bitwarden.ui.platform.manager.review.AppReviewManager
import com.x8bit.bitwarden.ui.vault.components.model.CreateVaultItemType
@@ -875,7 +874,7 @@ class VaultScreenTest : BitwardenComposeTest() {
}
composeTestRule
.onNodeWithText(text = "Premium subscription required")
.onNodeWithText(text = "Archive unavailable")
.assert(hasAnyAncestor(isDialog()))
.assertIsDisplayed()
composeTestRule
@@ -1642,7 +1641,7 @@ class VaultScreenTest : BitwardenComposeTest() {
@Test
fun `UpgradePremium action card should display when eligible`() {
mutableStateFlow.value = DEFAULT_STATE.copy(
premiumCard = PremiumCard.UPGRADE,
isPremiumUpgradeBannerEligible = true,
viewState = DEFAULT_CONTENT_VIEW_STATE,
)
@@ -1650,19 +1649,19 @@ class VaultScreenTest : BitwardenComposeTest() {
.onNodeWithText(text = "Unlock advanced security features")
.assertIsDisplayed()
composeTestRule
.onNodeWithText(text = "Learn more")
.onNodeWithText(text = "Upgrade to Premium")
.assertIsDisplayed()
}
@Test
fun `UpgradePremium action card CTA click should send ActionCardClick`() {
mutableStateFlow.value = DEFAULT_STATE.copy(
premiumCard = PremiumCard.UPGRADE,
isPremiumUpgradeBannerEligible = true,
viewState = DEFAULT_CONTENT_VIEW_STATE,
)
composeTestRule
.onNodeWithText(text = "Learn more")
.onNodeWithText(text = "Upgrade to Premium")
.assertIsDisplayed()
.performClick()
@@ -1678,7 +1677,7 @@ class VaultScreenTest : BitwardenComposeTest() {
@Test
fun `UpgradePremium action card dismiss click should send DismissActionCardClick`() {
mutableStateFlow.value = DEFAULT_STATE.copy(
premiumCard = PremiumCard.UPGRADE,
isPremiumUpgradeBannerEligible = true,
viewState = DEFAULT_CONTENT_VIEW_STATE,
)
@@ -1694,42 +1693,6 @@ class VaultScreenTest : BitwardenComposeTest() {
}
}
@Test
fun `PremiumNeedsAttention action card should display when eligible`() {
mutableStateFlow.value = DEFAULT_STATE.copy(
premiumCard = PremiumCard.NEEDS_ATTENTION,
viewState = DEFAULT_CONTENT_VIEW_STATE,
)
composeTestRule
.onNodeWithText(text = "Your subscription needs attention")
.assertIsDisplayed()
composeTestRule
.onNodeWithText(text = "View plan")
.assertIsDisplayed()
}
@Test
fun `PremiumNeedsAttention action card CTA click should send ActionCardClick`() {
mutableStateFlow.value = DEFAULT_STATE.copy(
premiumCard = PremiumCard.NEEDS_ATTENTION,
viewState = DEFAULT_CONTENT_VIEW_STATE,
)
composeTestRule
.onNodeWithText(text = "View plan")
.assertIsDisplayed()
.performClick()
verify(exactly = 1) {
viewModel.trySendAction(
VaultAction.ActionCardClick(
actionCard = VaultState.ActionCardState.PremiumNeedsAttention,
),
)
}
}
@Test
fun `UpgradedToPremium action card should display when eligible`() {
mutableStateFlow.value = DEFAULT_STATE.copy(

View File

@@ -31,7 +31,6 @@ import com.x8bit.bitwarden.data.auth.repository.model.ValidatePasswordResult
import com.x8bit.bitwarden.data.auth.repository.model.createMockOrganization
import com.x8bit.bitwarden.data.autofill.manager.browser.BrowserAutofillDialogManager
import com.x8bit.bitwarden.data.billing.manager.PremiumStateManager
import com.x8bit.bitwarden.data.billing.model.PremiumCard
import com.x8bit.bitwarden.data.platform.manager.CredentialExchangeRegistryManager
import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager
import com.x8bit.bitwarden.data.platform.manager.FirstTimeActionManager
@@ -238,12 +237,11 @@ class VaultViewModelTest : BaseViewModelTest() {
every { getFeatureFlagFlow(FlagKey.NewItemTypes) } returns mutableNewItemTypesFlagFlow
}
private val mutablePremiumUpgradeBannerEligibleFlow =
MutableStateFlow(PremiumCard.NONE)
private val mutablePremiumUpgradeBannerEligibleFlow = MutableStateFlow(false)
private val mutableUpgradedToPremiumCardEligibleFlow = MutableStateFlow(false)
private val premiumStateManager: PremiumStateManager = mockk {
every {
premiumCardStateFlow
isPremiumUpgradeBannerEligibleFlow
} returns mutablePremiumUpgradeBannerEligibleFlow
every {
isUpgradedToPremiumCardEligibleFlow
@@ -409,14 +407,14 @@ class VaultViewModelTest : BaseViewModelTest() {
viewModel.stateFlow.test {
assertEquals(DEFAULT_STATE, awaitItem())
mutablePremiumUpgradeBannerEligibleFlow.value = PremiumCard.UPGRADE
mutablePremiumUpgradeBannerEligibleFlow.value = true
assertEquals(
DEFAULT_STATE.copy(
premiumCard = PremiumCard.UPGRADE,
isPremiumUpgradeBannerEligible = true,
),
awaitItem(),
)
mutablePremiumUpgradeBannerEligibleFlow.value = PremiumCard.NONE
mutablePremiumUpgradeBannerEligibleFlow.value = false
assertEquals(DEFAULT_STATE, awaitItem())
}
}
@@ -455,48 +453,11 @@ class VaultViewModelTest : BaseViewModelTest() {
}
}
@Test
fun `ActionCardClick with PremiumNeedsAttention should emit NavigateToUpgradePremium`() =
runTest {
val viewModel = createViewModel()
viewModel.eventFlow.test {
viewModel.trySendAction(
VaultAction.ActionCardClick(
VaultState.ActionCardState.PremiumNeedsAttention,
),
)
assertEquals(
VaultEvent.NavigateToUpgradePremium,
awaitItem(),
)
}
}
@Test
fun `DismissActionCardClick with PremiumNeedsAttention should do nothing`() =
runTest {
val viewModel = createViewModel()
viewModel.eventFlow.test {
viewModel.trySendAction(
VaultAction.DismissActionCardClick(
VaultState.ActionCardState.PremiumNeedsAttention,
),
)
expectNoEvents()
}
verify(exactly = 0) {
premiumStateManager.dismissPremiumUpgradeBanner()
premiumStateManager.dismissUpgradedToPremiumCard()
}
}
@Test
fun `actionCard should return UpgradePremium when eligible and content is showing`() {
val contentViewState = DEFAULT_CONTENT_VIEW_STATE
val state = createMockVaultState(viewState = contentViewState).copy(
premiumCard = PremiumCard.UPGRADE,
isPremiumUpgradeBannerEligible = true,
)
assertEquals(
@@ -510,7 +471,7 @@ class VaultViewModelTest : BaseViewModelTest() {
val contentViewState = DEFAULT_CONTENT_VIEW_STATE
val state = createMockVaultState(viewState = contentViewState).copy(
isUpgradedToPremiumCardEligible = true,
premiumCard = PremiumCard.UPGRADE,
isPremiumUpgradeBannerEligible = true,
isPremium = true,
isIntroducingArchiveActionCardDismissed = false,
)
@@ -564,7 +525,7 @@ class VaultViewModelTest : BaseViewModelTest() {
fun `actionCard should return IntroducingArchive when not eligible for Premium upgrade`() {
val contentViewState = DEFAULT_CONTENT_VIEW_STATE
val state = createMockVaultState(viewState = contentViewState).copy(
premiumCard = PremiumCard.NONE,
isPremiumUpgradeBannerEligible = false,
isPremium = true,
isIntroducingArchiveActionCardDismissed = false,
)
@@ -579,7 +540,7 @@ class VaultViewModelTest : BaseViewModelTest() {
fun `actionCard should return null when not eligible for either card`() {
val contentViewState = DEFAULT_CONTENT_VIEW_STATE
val state = createMockVaultState(viewState = contentViewState).copy(
premiumCard = PremiumCard.NONE,
isPremiumUpgradeBannerEligible = false,
isPremium = false,
isIntroducingArchiveActionCardDismissed = false,
)
@@ -4708,7 +4669,7 @@ private fun createMockVaultState(
hasShownDecryptionFailureAlert = false,
restrictItemTypesPolicyOrgIds = emptyList(),
isIntroducingArchiveActionCardDismissed = false,
premiumCard = PremiumCard.NONE,
isPremiumUpgradeBannerEligible = false,
validTotpIds = validTotpIds.toImmutableSet(),
)

View File

@@ -30,7 +30,7 @@ androidxRoom = "2.8.4"
androidxSecurityCrypto = "1.1.0"
androidxSplash = "1.2.0"
androidxWork = "2.11.2"
bitwardenSdk = "3.0.0-7409-c9f9dba4"
bitwardenSdk = "3.0.0-7459-378df4c3"
crashlytics = "3.0.7"
detekt = "1.23.8"
firebaseBom = "34.14.0"

View File

@@ -6,44 +6,22 @@ import kotlinx.serialization.Serializable
/**
* Request body for resetting the password.
*
* @property currentPasswordHash The hash of the user's current password.
* @property passwordHint The hint for the master password (nullable).
* @property authenticationData The data to authenticate with a master password.
* @property unlockData The data to unlock with a master password.
* @param currentPasswordHash The hash of the user's current password.
* @param newPasswordHash The hash of the user's new password.
* @param passwordHint The hint for the master password (nullable).
* @param key The user key for the request (encrypted).
*/
@Serializable
data class ResetPasswordRequestJson(
@SerialName("masterPasswordHash")
val currentPasswordHash: String?,
@SerialName("newMasterPasswordHash")
val newPasswordHash: String,
@SerialName("masterPasswordHint")
val passwordHint: String?,
@SerialName("authenticationData")
val authenticationData: MasterPasswordAuthenticationDataJson,
@SerialName("unlockData")
val unlockData: MasterPasswordUnlockDataJson,
) {
constructor(
currentPasswordHash: String?,
passwordHint: String?,
kdf: KdfJson,
salt: String,
masterPasswordAuthenticationHash: String,
masterKeyWrappedUserKey: String,
) : this(
currentPasswordHash = currentPasswordHash,
passwordHint = passwordHint,
authenticationData = MasterPasswordAuthenticationDataJson(
kdf = kdf,
salt = salt,
masterPasswordAuthenticationHash = masterPasswordAuthenticationHash,
),
unlockData = MasterPasswordUnlockDataJson(
kdf = kdf,
salt = salt,
masterKeyWrappedUserKey = masterKeyWrappedUserKey,
),
)
}
@SerialName("key")
val key: String,
)

View File

@@ -4,110 +4,59 @@ import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Request body for setting the password.
* Request body for resetting the password.
*
* @property kdfType The KDF type.
* @property kdfIterations The number of iterations when calculating a user's password.
* @property kdfMemory The amount of memory to use when calculating a password hash (MB).
* @property kdfParallelism The number of threads to use when calculating a password hash.
* @param key The user key for the request (encrypted).
* @param keys A [Keys] object containing public and private keys.
* @param organizationIdentifier The SSO organization identifier.
* @param passwordHash The hash of the user's new password.
* @param passwordHint The hint for the master password (nullable).
*/
@Serializable
sealed class SetPasswordRequestJson {
data class SetPasswordRequestJson(
@SerialName("kdf")
val kdfType: KdfTypeJson? = null,
@SerialName("kdfIterations")
val kdfIterations: Int? = null,
@SerialName("kdfMemory")
val kdfMemory: Int? = null,
@SerialName("kdfParallelism")
val kdfParallelism: Int? = null,
@SerialName("key")
val key: String,
@SerialName("keys")
val keys: Keys?,
@SerialName("orgIdentifier")
val organizationIdentifier: String,
@SerialName("masterPasswordHash")
val passwordHash: String?,
@SerialName("masterPasswordHint")
val passwordHint: String?,
) {
/**
* Request body for setting the password in a v2 flow.
* A keys object containing public and private keys.
*
* @property organizationIdentifier The SSO organization identifier.
* @property passwordHint The hint for the master password (nullable).
* @property authenticationData The data to authenticate with a master password.
* @property unlockData The data to unlock with a master password.
* @param publicKey the public key (encrypted).
* @param encryptedPrivateKey the private key (encrypted).
*/
@Serializable
data class V2(
@SerialName("orgIdentifier")
val organizationIdentifier: String,
data class Keys(
@SerialName("publicKey")
val publicKey: String,
@SerialName("masterPasswordHint")
val passwordHint: String?,
@SerialName("masterPasswordAuthentication")
val authenticationData: MasterPasswordAuthenticationDataJson,
@SerialName("masterPasswordUnlock")
val unlockData: MasterPasswordUnlockDataJson,
) : SetPasswordRequestJson() {
constructor(
organizationIdentifier: String,
passwordHint: String?,
kdf: KdfJson,
salt: String,
masterPasswordAuthenticationHash: String,
masterKeyWrappedUserKey: String,
) : this(
organizationIdentifier = organizationIdentifier,
passwordHint = passwordHint,
authenticationData = MasterPasswordAuthenticationDataJson(
kdf = kdf,
salt = salt,
masterPasswordAuthenticationHash = masterPasswordAuthenticationHash,
),
unlockData = MasterPasswordUnlockDataJson(
kdf = kdf,
salt = salt,
masterKeyWrappedUserKey = masterKeyWrappedUserKey,
),
)
}
/**
* Request body for setting the password in a v1 flow.
*
* @property kdfType The KDF type.
* @property kdfIterations The number of iterations when calculating a user's password.
* @property kdfMemory The amount of memory to use when calculating a password hash (MB).
* @property kdfParallelism The number of threads to use when calculating a password hash.
* @property key The user key for the request (encrypted).
* @property keys A [Keys] object containing public and private keys.
* @property organizationIdentifier The SSO organization identifier.
* @property passwordHash The hash of the user's new password.
* @property passwordHint The hint for the master password (nullable).
*/
@Serializable
data class V1(
@SerialName("kdf")
val kdfType: KdfTypeJson? = null,
@SerialName("kdfIterations")
val kdfIterations: Int? = null,
@SerialName("kdfMemory")
val kdfMemory: Int? = null,
@SerialName("kdfParallelism")
val kdfParallelism: Int? = null,
@SerialName("key")
val key: String,
@SerialName("keys")
val keys: Keys?,
@SerialName("orgIdentifier")
val organizationIdentifier: String,
@SerialName("masterPasswordHash")
val passwordHash: String?,
@SerialName("masterPasswordHint")
val passwordHint: String?,
) : SetPasswordRequestJson() {
/**
* A keys object containing public and private keys.
*
* @param publicKey the public key (encrypted).
* @param encryptedPrivateKey the private key (encrypted).
*/
@Serializable
data class Keys(
@SerialName("publicKey")
val publicKey: String,
@SerialName("encryptedPrivateKey")
val encryptedPrivateKey: String,
)
}
@SerialName("encryptedPrivateKey")
val encryptedPrivateKey: String,
)
}

View File

@@ -31,7 +31,7 @@ import kotlinx.serialization.json.Json
* The default implementation of the [AccountsService].
*/
@Suppress("TooManyFunctions")
internal class AccountsServiceImpl(
internal class AccountsServiceImpl constructor(
private val unauthenticatedAccountsApi: UnauthenticatedAccountsApi,
private val authenticatedAccountsApi: AuthenticatedAccountsApi,
private val unauthenticatedKeyConnectorApi: UnauthenticatedKeyConnectorApi,

View File

@@ -172,16 +172,9 @@ class AccountsServiceTest : BaseServiceTest() {
val result = service.resetPassword(
body = ResetPasswordRequestJson(
currentPasswordHash = "",
newPasswordHash = "",
passwordHint = null,
kdf = KdfJson(
iterations = 7,
memory = 1,
parallelism = 2,
kdfType = KdfTypeJson.ARGON2_ID,
),
salt = "",
masterPasswordAuthenticationHash = "",
masterKeyWrappedUserKey = "",
key = "",
),
)
assertTrue(result.isSuccess)
@@ -194,27 +187,20 @@ class AccountsServiceTest : BaseServiceTest() {
val result = service.resetPassword(
body = ResetPasswordRequestJson(
currentPasswordHash = null,
newPasswordHash = "",
passwordHint = null,
kdf = KdfJson(
iterations = 7,
memory = 1,
parallelism = 2,
kdfType = KdfTypeJson.ARGON2_ID,
),
salt = "",
masterPasswordAuthenticationHash = "",
masterKeyWrappedUserKey = "",
key = "",
),
)
assertTrue(result.isSuccess)
}
@Test
fun `setPassword with v1 request and empty response is success`() = runTest {
fun `setPassword with empty response is success`() = runTest {
val response = MockResponse().setBody("")
server.enqueue(response)
val result = service.setPassword(
body = SetPasswordRequestJson.V1(
body = SetPasswordRequestJson(
passwordHash = "passwordHash",
passwordHint = "passwordHint",
organizationIdentifier = "organizationId",
@@ -223,7 +209,7 @@ class AccountsServiceTest : BaseServiceTest() {
kdfParallelism = 2,
kdfType = null,
key = "encryptedUserKey",
keys = SetPasswordRequestJson.V1.Keys(
keys = SetPasswordRequestJson.Keys(
publicKey = "public",
encryptedPrivateKey = "private",
),
@@ -232,28 +218,6 @@ class AccountsServiceTest : BaseServiceTest() {
assertTrue(result.isSuccess)
}
@Test
fun `setPassword with v2 request and empty response is success`() = runTest {
val response = MockResponse().setBody("")
server.enqueue(response)
val result = service.setPassword(
body = SetPasswordRequestJson.V2(
masterPasswordAuthenticationHash = "passwordHash",
passwordHint = "passwordHint",
organizationIdentifier = "organizationId",
kdf = KdfJson(
iterations = 7,
memory = 1,
parallelism = 2,
kdfType = KdfTypeJson.ARGON2_ID,
),
salt = "sample@bitwarden.com",
masterKeyWrappedUserKey = "encryptedUserKey",
),
)
assertTrue(result.isSuccess)
}
@Test
fun `setKeyConnectorKey with token and empty response is success`() = runTest {
val response = MockResponse().setBody("")

View File

@@ -201,6 +201,7 @@ Scanning will happen automatically.</string>
<string name="copy_totp_automatically">Copy TOTP automatically</string>
<string name="premium_required">A Premium membership is required to use this feature.</string>
<string name="attachment_deleted">Attachment deleted</string>
<string name="attachments_unavailable">Attachments unavailable</string>
<string name="attachments_are_a_premium_feature">Attachments are a Premium feature. Your current plan does not include access to this feature.</string>
<string name="choose_file">Choose file</string>
<string name="file">File</string>
@@ -1209,14 +1210,12 @@ Do you want to switch to this account?</string>
<string name="unarchiving">Unarchiving</string>
<string name="item_moved_to_archived">Item moved to archive</string>
<string name="item_moved_to_vault">Item moved to vault</string>
<string name="archive_unavailable">Archive unavailable</string>
<string name="archiving_items_is_a_premium_feature">Archiving items is a Premium feature. Your current plan does not include access to this feature.</string>
<string name="upgrade_to_premium">Upgrade to Premium</string>
<string name="plan">Plan</string>
<string name="to_manage_your_premium_subscription_youll_need_to_login_to_your_web_vault_on_a_computer">To manage your Premium subscription, youll need to login to your web vault on a computer.</string>
<string name="unlock_advanced_security_features">Unlock advanced security features</string>
<string name="your_subscription_needs_attention">Your subscription needs attention</string>
<string name="check_your_plan_for_details">Check your plan for details.</string>
<string name="view_plan">View plan</string>
<string name="a_premium_plan_gives_you_more_tools_to_stay_secure_and_in_control">A Premium plan gives you more tools to stay secure and in control.</string>
<string name="this_item_is_archived">This item is archived.</string>
<string name="introducing_archive">Introducing archive</string>