mirror of
https://github.com/bitwarden/android.git
synced 2026-06-20 04:29:37 -05:00
Compare commits
9 Commits
agalles/cr
...
sdlc/sdk-u
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
35a1107258 | ||
|
|
a33b7c1ef7 | ||
|
|
af23f0ef1e | ||
|
|
a2ccb09502 | ||
|
|
6f1b848fe8 | ||
|
|
55934ed32b | ||
|
|
5943829f6c | ||
|
|
d9e5a161be | ||
|
|
67b57044ce |
280
.github/workflows/github-release.yml
vendored
280
.github/workflows/github-release.yml
vendored
@@ -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"
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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()
|
||||
|
||||
/**
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1079,7 +1079,7 @@ class SearchScreenTest : BitwardenComposeTest() {
|
||||
}
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText(text = "Premium subscription required")
|
||||
.onNodeWithText(text = "Archive unavailable")
|
||||
.assert(hasAnyAncestor(isDialog()))
|
||||
.assertIsDisplayed()
|
||||
composeTestRule
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -314,7 +314,7 @@ class VaultAddEditScreenTest : BitwardenComposeTest() {
|
||||
)
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText(text = "Premium subscription required")
|
||||
.onNodeWithText(text = "Archive unavailable")
|
||||
.assert(hasAnyAncestor(isDialog()))
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -275,7 +275,7 @@ class VaultItemScreenTest : BitwardenComposeTest() {
|
||||
}
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText(text = "Premium subscription required")
|
||||
.onNodeWithText(text = "Archive unavailable")
|
||||
.assert(hasAnyAncestor(isDialog()))
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@@ -2579,7 +2579,7 @@ class VaultItemListingScreenTest : BitwardenComposeTest() {
|
||||
}
|
||||
|
||||
composeTestRule
|
||||
.onNodeWithText(text = "Premium subscription required")
|
||||
.onNodeWithText(text = "Archive unavailable")
|
||||
.assert(hasAnyAncestor(isDialog()))
|
||||
.assertIsDisplayed()
|
||||
composeTestRule
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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("")
|
||||
|
||||
@@ -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, you’ll 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>
|
||||
|
||||
Reference in New Issue
Block a user