Compare commits

...

74 Commits

Author SHA1 Message Date
Amy Galles
1ef60f4a5f Merge branch 'main' into agalles/create-github-workflow-trigger 2026-06-18 16:39:02 -07:00
Amy Galles
4c915e1c27 create workflow trigger for github publish 2026-06-18 16:38:14 -07:00
David Perez
59b9cbfa31 PM-38356 PM-37177: Bug: Update premium subscription trouble-states (#7073) 2026-06-18 19:54:04 +00:00
David Perez
5b8dd94b07 PM-37319: Feat: Update premium dialog titles (#7072) 2026-06-17 19:45:57 +00:00
David Perez
843121154d PM-39072: Feat: Update premium action card copy (#7071) 2026-06-17 16:58:00 +00:00
David Perez
9f0b608253 PM-32858: Feat: Set and Reset Password flows should use newer request models (#7068) 2026-06-17 14:14:11 +00:00
David Perez
7d062b9617 PM-39138: Bug: Passport number should be hidden on edit screen (#7067) 2026-06-16 16:00:10 +00:00
David Perez
fe8bf57360 PM-39006: Bug: Update the setPassword flow for TDE users (#7056) 2026-06-16 14:18:09 +00:00
David Perez
afab585d1f PM-39082: Feat: Do not show accessibility disclosure at app startup for Fdroid (#7064) 2026-06-16 14:17:45 +00:00
renovate[bot]
74f7dea574 [deps]: Update googleBilling to v9 (#7036)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-15 17:48:23 +00:00
bw-ghapp[bot]
fbd579b973 Update SDK to 3.0.0-7409-c9f9dba4 (#7050)
Co-authored-by: bw-ghapp[bot] <178206702+bw-ghapp[bot]@users.noreply.github.com>
2026-06-15 17:46:31 +00:00
bw-ghapp[bot]
ce09418462 Crowdin Pull (#7058)
Co-authored-by: bitwarden-devops-bot <106330231+bitwarden-devops-bot@users.noreply.github.com>
2026-06-15 13:50:42 +00:00
aj-rosado
bf0770657f [PM-37255] feat: Consume fill-assist targeting rules data (#6990) 2026-06-15 10:50:04 +00:00
David Perez
2432665327 PM-38998: Feat: Apply optional auth token to config endpoint (#7055) 2026-06-12 20:40:56 +00:00
Dev Sharma
cc9b7d3edf [PM-38918] fix: PM-38644, UI/UX inconsistency: Passport number in Identity items is not a hidden field (#7053) 2026-06-12 15:19:13 +00:00
David Perez
453ade7cca Chore: Ensure UI it using common components and themeing (#7052) 2026-06-12 14:07:58 +00:00
David Perez
2355e89248 Chore: Isolate the MainActivity composable content from onCreate (#7051) 2026-06-10 18:52:23 +00:00
David Perez
38045c9464 Chore: Create a Overlay Navigation Screen (#7049) 2026-06-10 17:29:30 +00:00
David Perez
0f13d97cb1 PM-38779: Bug: Update cursor logic to avoid exception (#7044) 2026-06-09 21:55:10 +00:00
David Perez
2c7eef2b8c PM-38637: Bug: Add additional CRL distrobution server to clear-text permitted list (#7045) 2026-06-09 20:36:41 +00:00
David Perez
c431fcba9b Chore: Update the local app version (#7043) 2026-06-09 16:44:20 +00:00
David Perez
2706c89302 Chore: Add a VaultUnlockResult helper method to handle successes (#7041) 2026-06-09 16:41:30 +00:00
bw-ghapp[bot]
af665cbe82 Update SDK to 3.0.0-7338-5bdc976f (#7007)
Co-authored-by: bw-ghapp[bot] <178206702+bw-ghapp[bot]@users.noreply.github.com>
2026-06-09 16:22:07 +00:00
David Perez
2572757cda PM-38745: feat: Update accessibility UI (#7037) 2026-06-09 14:31:49 +00:00
Corbet
fbed80c904 [QA-1911] Add testTag to Flight Recorder logging duration dropdown (#7040) 2026-06-08 19:31:40 +00:00
David Perez
6930d077aa PM-38625: Chore: Store the WrappedAccountCryptographicState (#7030) 2026-06-08 16:31:52 +00:00
renovate[bot]
fbbbe578e5 [deps]: Update kotlin to v2.3.9 (#7034)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-08 15:24:34 +00:00
renovate[bot]
378f77a55b [deps]: Update mockk to v1.14.11 (#7035)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-08 14:32:26 +00:00
bw-ghapp[bot]
b3cd24c6e9 Crowdin Pull (#7032)
Co-authored-by: bitwarden-devops-bot <106330231+bitwarden-devops-bot@users.noreply.github.com>
2026-06-08 14:15:01 +00:00
David Perez
b85e30fba2 PM-38610: Bug: Update BitwardenBasicDialog to allow scrolling content (#7028) 2026-06-08 13:48:48 +00:00
David Perez
2022507cc0 PM-38618: Feat: Update Accessibility Service disclosure text (#7026) 2026-06-05 21:15:33 +00:00
David Perez
c0caaf9ce9 PM-38513: Bug: Do not emit policies before we have recieved them (#7023) 2026-06-05 18:06:20 +00:00
David Perez
fdeafd7388 PM-38587: Feat: Add accessibility service disclaimer at startup (#7018) 2026-06-04 21:43:09 +00:00
David Perez
9b85e028f7 PM-38534: bug: Update LoginResult to use friendly error message (#7017) 2026-06-04 16:12:21 +00:00
David Perez
0cd9c99fcf BWA-252: bug: Empty string totp codes should not be counted as a totp code (#7015) 2026-06-04 16:12:04 +00:00
David Perez
86cf21602c PM-38280: Fix: Update collection API to V2 (#7014) 2026-06-04 14:06:02 +00:00
Patrick Honkonen
c45ae58b0e [PM-37571] chore: Remove unused isSdkSupported guard (#7011) 2026-06-03 15:03:50 +00:00
Patrick Honkonen
d44212d9bd [PM-38364] fix: Multiply subscription line-item cost by quantity (#7012) 2026-06-03 14:18:56 +00:00
aikido-autofix[bot]
1c90dc242f [AppSec] AI Fix for Template Injection in GitHub Workflows Action (#6784)
Co-authored-by: aikido-autofix[bot] <119856028+aikido-autofix[bot]@users.noreply.github.com>
2026-06-02 21:33:45 +00:00
David Perez
22ad8ec78f Chore: Add gradle lockfiles (#7008) 2026-06-02 20:52:53 +00:00
Patrick Honkonen
b5a6ab0ac0 [PM-37571] feat: Map Passport and License to SDK types (#7009) 2026-06-02 20:29:20 +00:00
David Perez
ecbceb8baf PM-37887: Feat: Update the Key Connector vault unlock to use the SDK (#6999) 2026-06-02 20:20:10 +00:00
David Perez
bd45d5f56a PM-38479: Feat: Update the RemovePasswordScreen UI (#7010) 2026-06-02 20:02:04 +00:00
David Perez
051b8b53d1 PM-38358: Chore: Remove user key (#7004) 2026-06-02 19:15:47 +00:00
bw-ghapp[bot]
9c8b4891a1 Crowdin Pull (#7001)
Co-authored-by: bitwarden-devops-bot <106330231+bitwarden-devops-bot@users.noreply.github.com>
2026-06-02 18:10:30 +00:00
David Perez
5fd32ab391 Deps: Update the Gradle Wrapper to v9.5.1 (#7006) 2026-06-02 15:46:02 +00:00
bw-ghapp[bot]
57a14f2785 Update SDK to 3.0.0-7198-7bca9fca (#6983)
Co-authored-by: bw-ghapp[bot] <178206702+bw-ghapp[bot]@users.noreply.github.com>
2026-06-02 15:01:01 +00:00
Álison Fernandes
c18dd58c41 [PM-38411] tech-debt: Update deprecated argument in actions/create-github-app-token (#7003) 2026-06-01 18:42:45 +00:00
David Perez
227359bc26 PM-26577: Feat: Support multiple schemes for Duo, WebAuthn, and SSO callbacks (#6339) 2026-06-01 16:09:16 +00:00
github-actions[bot]
fa219f6963 Update Google privileged browsers list (#7000)
Co-authored-by: GitHub Actions Bot <actions@github.com>
2026-06-01 13:57:13 +00:00
David Perez
a3bcff9463 PM-37911: Feat: Update Organization model (#6960) 2026-05-29 18:07:10 +00:00
Patrick Honkonen
aca9949874 [PM-37920] fix: Show Storage cost row when additional storage is present (#6997) 2026-05-29 17:26:21 +00:00
David Perez
217bfc1097 Chore: Move dispatcher for sdk functions into SDK sources (#6995) 2026-05-29 16:58:10 +00:00
David Perez
a94978c8e2 Deps: Update Firebase BOM to v34.14.0 (#6996) 2026-05-29 16:34:23 +00:00
David Perez
0a920d5800 Deps: Update the Compose BOM to v2026.05.01 (#6998) 2026-05-29 16:34:10 +00:00
Patrick Honkonen
09f0f5b9bf [PM-38263] fix: Reference invoices in past due subscription description (#6994) 2026-05-29 15:19:28 +00:00
ifernandezdiaz
b57fb9c437 [QA-1826] Adding missing testTags for Authenticator/PM apps (#6993) 2026-05-29 13:37:12 +00:00
aj-rosado
124ce37bc3 [PM-38118] fix: Support Firefox updated toolbar in accessibility autofill (#6986) 2026-05-29 13:35:50 +00:00
Patrick Honkonen
e7e2c26bef [PM-36970] fix: Correct Update Payment status description (#6988) 2026-05-28 21:37:06 +00:00
Patrick Honkonen
c89a52e5d2 [PM-38279] fix: Hide Cancel Premium action for Update payment status (#6989) 2026-05-28 21:14:09 +00:00
David Perez
fb955e903f Deps: Update the protobuf library to v4.35.0 (#6985) 2026-05-28 20:44:14 +00:00
Patrick Honkonen
230c8f769d [PM-37181] feat: Surface Expired subscription substate (#6982) 2026-05-28 20:37:50 +00:00
David Perez
8661dfaf2f PM-38285: Feat: Filter unconfirmed organizations from the app (#6987) 2026-05-28 19:48:07 +00:00
David Perez
a872db128b Chore: Remove the Manager type from OrganizationType (#6984) 2026-05-28 19:47:41 +00:00
Patrick Honkonen
40604d0ec0 [PM-37804] fix: Drop redundant Stripe checkout confirmation on Upgrade Now (#6980) 2026-05-28 19:08:00 +00:00
bw-ghapp[bot]
0f4b3fb9f0 Update SDK to 3.0.0-7126-025e5d85 (#6976)
Co-authored-by: bw-ghapp[bot] <178206702+bw-ghapp[bot]@users.noreply.github.com>
2026-05-28 10:38:48 +00:00
David Perez
a0edef99e6 PM-38140 Feat: SDK policy filters (#6979) 2026-05-27 20:59:53 +00:00
Patrick Honkonen
cc6fcecc5b [PM-37232] fix: Hide upgrade CTAs while a Premium upgrade is pending (#6978) 2026-05-27 20:53:06 +00:00
David Perez
58408bcd77 PM-38130: Feat: Parse new organizations and policies properties from sync response (#6977) 2026-05-27 09:52:12 +00:00
David Perez
3732672ab4 PM-37985: Feat: Use PolicyView in the app (#6966) 2026-05-26 16:34:10 +00:00
renovate[bot]
b53d3fbd29 [deps]: Update com.google.devtools.ksp to v2.3.8 (#6971)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-26 15:08:30 +00:00
bw-ghapp[bot]
f0f1f91c62 Update SDK to 3.0.0-7068-a635e32d (#6967)
Co-authored-by: bw-ghapp[bot] <178206702+bw-ghapp[bot]@users.noreply.github.com>
2026-05-26 14:48:57 +00:00
Patrick Honkonen
ecc47005fb [PM-37282] feat: Add Upgrade to Premium CTA to File Send dialog (#6968) 2026-05-26 14:40:15 +00:00
bw-ghapp[bot]
3fc5965a05 Crowdin Pull (#6969)
Co-authored-by: bitwarden-devops-bot <106330231+bitwarden-devops-bot@users.noreply.github.com>
2026-05-26 14:25:24 +00:00
345 changed files with 12493 additions and 6750 deletions

View File

@@ -50,7 +50,7 @@ jobs:
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
id: app-token
with:
app-id: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-ID }}
client-id: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-ID }}
private-key: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-KEY }}
permission-contents: write # for creating and pushing a new branch
permission-pull-requests: write # for creating pull request

View File

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

View File

@@ -169,8 +169,9 @@ jobs:
- name: Enable Publish Github Release Workflow
env:
PRODUCT: ${{ inputs.product }}
DRY_RUN: ${{ inputs.dry-run }}
run: |
if ${{ inputs.dry-run }} ; then
if $DRY_RUN ; then
gh workflow view publish-github-release-bwpm.yml
exit 0
fi

View File

@@ -56,7 +56,7 @@ jobs:
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
id: app-token
with:
app-id: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-ID }}
client-id: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-ID }}
private-key: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-KEY }}
permission-pull-requests: write
permission-actions: read

View File

@@ -18,6 +18,7 @@
<!-- CRL Distribution Servers -->
<domain includeSubdomains="true">c.lencr.org</domain>
<domain includeSubdomains="true">c.pki.goog</domain>
<domain includeSubdomains="true">crls.certainly.com</domain>
<!-- OCSP Responder Servers -->
<domain includeSubdomains="true">o.pki.goog</domain>

View File

@@ -170,6 +170,7 @@
<data android:scheme="https" />
<data android:host="bitwarden.com" />
<data android:host="bitwarden.eu" />
<data android:host="bitwarden.pw" />
<data android:pathPattern="/duo-callback" />
<data android:pathPattern="/sso-callback" />
<data android:pathPattern="/webauthn-callback" />

View File

@@ -645,6 +645,10 @@
"info": {
"package_name": "com.heytap.browser",
"signatures": [
{
"build": "release",
"cert_fingerprint_sha256": "B2:9A:A0:BB:DC:9F:D9:DE:F5:5D:C5:6E:A7:D7:45:76:D5:84:6C:BC:F5:E5:AB:D3:05:E2:D9:31:9E:4F:42:AE"
},
{
"build": "release",
"cert_fingerprint_sha256": "AF:F8:A7:49:CF:0E:7D:75:44:65:D0:FB:FA:7B:8D:0C:64:5E:22:5C:10:C6:E2:32:AD:A0:D9:74:88:36:B8:E5"
@@ -656,6 +660,18 @@
]
}
},
{
"type": "android",
"info": {
"package_name": "com.oplus.credential",
"signatures": [
{
"build": "release",
"cert_fingerprint_sha256": "E4:98:02:40:95:84:CE:53:15:2A:90:00:82:0A:51:E4:FA:8A:72:3B:7B:CC:26:3E:33:52:40:AC:F1:00:BF:9E"
}
]
}
},
{
"type": "android",
"info": {

View File

@@ -15,13 +15,13 @@ import androidx.browser.auth.AuthTabIntent
import androidx.compose.foundation.background
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.core.app.ActivityCompat
import androidx.core.os.LocaleListCompat
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavController
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import com.bitwarden.annotation.OmitFromCoverage
import com.bitwarden.ui.platform.base.util.EventsEffect
@@ -36,15 +36,11 @@ import com.x8bit.bitwarden.data.platform.manager.util.ObserveScreenDataEffect
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
import com.x8bit.bitwarden.ui.platform.components.util.rememberBitwardenNavController
import com.x8bit.bitwarden.ui.platform.composition.LocalManagerProvider
import com.x8bit.bitwarden.ui.platform.feature.cookieacquisition.cookieAcquisitionDestination
import com.x8bit.bitwarden.ui.platform.feature.cookieacquisition.navigateToCookieAcquisition
import com.x8bit.bitwarden.ui.platform.feature.debugmenu.debugMenuDestination
import com.x8bit.bitwarden.ui.platform.feature.debugmenu.manager.DebugMenuLaunchManager
import com.x8bit.bitwarden.ui.platform.feature.debugmenu.navigateToDebugMenuScreen
import com.x8bit.bitwarden.ui.platform.feature.localnetworkaccess.localNetworkAccessDestination
import com.x8bit.bitwarden.ui.platform.feature.localnetworkaccess.navigateToLocalNetworkAccess
import com.x8bit.bitwarden.ui.platform.feature.rootnav.RootNavigationRoute
import com.x8bit.bitwarden.ui.platform.feature.rootnav.rootNavDestination
import com.x8bit.bitwarden.ui.platform.feature.overlaynav.OverlayNavRoute
import com.x8bit.bitwarden.ui.platform.feature.overlaynav.overlayNavDestination
import com.x8bit.bitwarden.ui.platform.feature.settings.appearance.model.AppLanguage
import com.x8bit.bitwarden.ui.platform.model.AuthTabLaunchers
import com.x8bit.bitwarden.ui.platform.util.appLanguage
@@ -130,45 +126,13 @@ class MainActivity : AppCompatActivity() {
SetupEventsEffect(navController = navController)
val state by mainViewModel.stateFlow.collectAsStateWithLifecycle()
updateScreenCapture(isScreenCaptureAllowed = state.isScreenCaptureAllowed)
LocalManagerProvider(
featureFlagsState = state.featureFlagsState,
MainActivityContent(
state = state,
authTabLaunchers = authTabLaunchers,
) {
ObserveScreenDataEffect(
onDataUpdate = remember(mainViewModel) {
{ mainViewModel.trySendAction(MainAction.ResumeScreenDataReceived(it)) }
},
)
BitwardenTheme(
theme = state.theme,
dynamicColor = state.isDynamicColorsEnabled,
) {
NavHost(
navController = navController,
startDestination = RootNavigationRoute,
modifier = Modifier
.background(color = BitwardenTheme.colorScheme.background.primary),
) {
// Root navigation, debug menu, and cookie acquisition exist at
// this top level. They can appear on top of the rest of the app
// without interacting with the state-based navigation used by
// RootNavScreen.
rootNavDestination { shouldShowSplashScreen = false }
debugMenuDestination(
onNavigateBack = { navController.popBackStack() },
onSplashScreenRemoved = { shouldShowSplashScreen = false },
)
cookieAcquisitionDestination(
onDismiss = { navController.popBackStack() },
onSplashScreenRemoved = { shouldShowSplashScreen = false },
)
localNetworkAccessDestination(
onDismiss = { navController.popBackStack() },
onSplashScreenRemoved = { shouldShowSplashScreen = false },
)
}
}
}
navController = navController,
sendAction = mainViewModel::trySendAction,
onSplashScreenRemoved = { shouldShowSplashScreen = false },
)
}
}
@@ -200,7 +164,7 @@ class MainActivity : AppCompatActivity() {
locales.get(0)?.appLanguage
}
} else {
// For older versions, use what ever language is available from the repository.
// For older versions, use whatever language is available from the repository.
settingsRepository.appLanguage
}
@@ -249,11 +213,6 @@ class MainActivity : AppCompatActivity() {
is MainEvent.CompleteAutofill -> handleCompleteAutofill(event)
MainEvent.Recreate -> handleRecreate()
MainEvent.NavigateToDebugMenu -> navController.navigateToDebugMenuScreen()
MainEvent.NavigateToCookieAcquisition -> navController.navigateToCookieAcquisition()
MainEvent.NavigateToLocalNetworkAccess -> {
navController.navigateToLocalNetworkAccess()
}
is MainEvent.UpdateAppLocale -> {
AppCompatDelegate.setApplicationLocales(
LocaleListCompat.forLanguageTags(event.localeName),
@@ -297,3 +256,38 @@ class MainActivity : AppCompatActivity() {
}
}
}
@OmitFromCoverage
@Composable
private fun MainActivityContent(
state: MainState,
authTabLaunchers: AuthTabLaunchers,
navController: NavHostController,
sendAction: (MainAction) -> Unit,
onSplashScreenRemoved: () -> Unit,
) {
LocalManagerProvider(
featureFlagsState = state.featureFlagsState,
authTabLaunchers = authTabLaunchers,
) {
ObserveScreenDataEffect { sendAction(MainAction.ResumeScreenDataReceived(it)) }
BitwardenTheme(
theme = state.theme,
dynamicColor = state.isDynamicColorsEnabled,
) {
NavHost(
navController = navController,
startDestination = OverlayNavRoute,
modifier = Modifier.background(BitwardenTheme.colorScheme.background.primary),
) {
// The OverlayNav and Debug destinations are the only UIs that can be
// displayed here, everything else should be inside the OverlayNav.
overlayNavDestination(onSplashScreenRemoved = onSplashScreenRemoved)
debugMenuDestination(
onNavigateBack = { navController.popBackStack() },
onSplashScreenRemoved = onSplashScreenRemoved,
)
}
}
}
}

View File

@@ -31,18 +31,17 @@ import com.x8bit.bitwarden.data.billing.util.getPremiumCheckoutCallbackResult
import com.x8bit.bitwarden.data.credentials.manager.CredentialProviderRequestManager
import com.x8bit.bitwarden.data.credentials.manager.model.CredentialProviderRequest
import com.x8bit.bitwarden.data.platform.manager.AppResumeManager
import com.x8bit.bitwarden.data.platform.manager.CookieAcquisitionRequestManager
import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManager
import com.x8bit.bitwarden.data.platform.manager.garbage.GarbageCollectionManager
import com.x8bit.bitwarden.data.platform.manager.model.AppResumeScreenData
import com.x8bit.bitwarden.data.platform.manager.model.CompleteRegistrationData
import com.x8bit.bitwarden.data.platform.manager.model.SpecialCircumstance
import com.x8bit.bitwarden.data.platform.manager.network.NetworkPermissionManager
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
import com.x8bit.bitwarden.data.platform.util.isAddTotpLoginItemFromAuthenticator
import com.x8bit.bitwarden.data.vault.manager.model.VaultStateEvent
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.ui.platform.feature.rootnav.RootNavViewModel
import com.x8bit.bitwarden.ui.platform.feature.settings.appearance.model.AppLanguage
import com.x8bit.bitwarden.ui.platform.model.FeatureFlagsState
import com.x8bit.bitwarden.ui.platform.util.isAccountSecurityShortcut
@@ -56,7 +55,6 @@ import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
@@ -80,8 +78,6 @@ private const val ANIMATION_DEBOUNCE_DELAY_MS = 500L
class MainViewModel @Inject constructor(
accessibilitySelectionManager: AccessibilitySelectionManager,
autofillSelectionManager: AutofillSelectionManager,
cookieAcquisitionRequestManager: CookieAcquisitionRequestManager,
networkPermissionManager: NetworkPermissionManager,
private val addTotpItemFromAuthenticatorManager: AddTotpItemFromAuthenticatorManager,
private val specialCircumstanceManager: SpecialCircumstanceManager,
private val garbageCollectionManager: GarbageCollectionManager,
@@ -170,20 +166,6 @@ class MainViewModel @Inject constructor(
.onEach(::sendAction)
.launchIn(viewModelScope)
networkPermissionManager
.isLocalNetworkAccessRequiredStateFlow
.filter { it }
.map { MainAction.Internal.LocalNetworkAccessRequired }
.onEach(::sendAction)
.launchIn(viewModelScope)
cookieAcquisitionRequestManager
.cookieAcquisitionRequestFlow
.filterNotNull()
.map { MainAction.Internal.CookieAcquisitionReady }
.onEach(::sendAction)
.launchIn(viewModelScope)
// On app launch, mark all active users as having previously logged in.
// This covers any users who are active prior to this value being recorded.
viewModelScope.launch {
@@ -232,8 +214,6 @@ class MainViewModel @Inject constructor(
is MainAction.Internal.ScreenCaptureUpdate -> handleScreenCaptureUpdate(action)
is MainAction.Internal.ThemeUpdate -> handleAppThemeUpdated(action)
is MainAction.Internal.DynamicColorsUpdate -> handleDynamicColorsUpdate(action)
is MainAction.Internal.CookieAcquisitionReady -> handleCookieAcquisitionReady()
is MainAction.Internal.LocalNetworkAccessRequired -> handleLocalNetworkAccessRequired()
is MainAction.Internal.ResizeHasBeenRequested -> handleResizeHasBeenRequested()
}
}
@@ -315,14 +295,6 @@ class MainViewModel @Inject constructor(
mutableStateFlow.update { it.copy(isDynamicColorsEnabled = action.isDynamicColorsEnabled) }
}
private fun handleCookieAcquisitionReady() {
sendEvent(MainEvent.NavigateToCookieAcquisition)
}
private fun handleLocalNetworkAccessRequired() {
sendEvent(MainEvent.NavigateToLocalNetworkAccess)
}
private fun handleResizeHasBeenRequested() {
mutableStateFlow.update { it.copy(hasResizeBeenRequested = true) }
}
@@ -677,17 +649,6 @@ sealed class MainAction {
val isDynamicColorsEnabled: Boolean,
) : Internal()
/**
* Indicates that the cookie acquisition conditions are met and navigation
* should proceed.
*/
data object CookieAcquisitionReady : Internal()
/**
* Indicates that the local network access is required.
*/
data object LocalNetworkAccessRequired : Internal()
/**
* Indicates that resize has been requested on the Activity
*/
@@ -721,16 +682,6 @@ sealed class MainEvent {
*/
data object NavigateToDebugMenu : MainEvent()
/**
* Navigate to the cookie acquisition screen.
*/
data object NavigateToCookieAcquisition : MainEvent()
/**
* Navigate to the local network access screen.
*/
data object NavigateToLocalNetworkAccess : MainEvent()
/**
* Indicates that the app language has been updated.
*/

View File

@@ -1,6 +1,6 @@
package com.x8bit.bitwarden.data.auth.datasource.disk
import com.bitwarden.network.model.AccountKeysJson
import com.bitwarden.core.WrappedAccountCryptographicState
import com.bitwarden.network.model.SyncResponseJson
import com.bitwarden.network.provider.AppIdProvider
import com.x8bit.bitwarden.data.auth.datasource.disk.model.AccountTokensJson
@@ -114,16 +114,6 @@ interface AuthDiskSource : AppIdProvider {
invalidUnlockAttempts: Int?,
)
/**
* Retrieves a user key using a [userId].
*/
fun getUserKey(userId: String): String?
/**
* Stores a user key using a [userId].
*/
fun storeUserKey(userId: String, userKey: String?)
/**
* Retrieves the local user data key for the given [userId].
*/
@@ -135,34 +125,16 @@ interface AuthDiskSource : AppIdProvider {
fun storeLocalUserDataKey(userId: String, wrappedKey: String?)
/**
* Retrieves a private key using a [userId].
* Returns the Wrapped Account Cryptographic State for the given [userId].
*/
@Deprecated(
message = "Use getAccountKeys instead.",
replaceWith = ReplaceWith("getAccountKeys"),
)
fun getPrivateKey(userId: String): String?
fun getAccountCryptographicState(userId: String): WrappedAccountCryptographicState?
/**
* Stores a private key using a [userId].
* Stores the Wrapped Account Cryptographic State for a given [userId].
*/
@Deprecated(
message = "Use storeAccountKeys instead.",
replaceWith = ReplaceWith("storeAccountKeys"),
)
fun storePrivateKey(userId: String, privateKey: String?)
/**
* Returns the profile account keys for the given [userId].
*/
fun getAccountKeys(userId: String): AccountKeysJson?
/**
* Stores the profile account keys for the given [userId].
*/
fun storeAccountKeys(
fun storeAccountCryptographicState(
userId: String,
accountKeys: AccountKeysJson?,
accountCryptographicState: WrappedAccountCryptographicState?,
)
/**

View File

@@ -1,6 +1,7 @@
package com.x8bit.bitwarden.data.auth.datasource.disk
import android.content.SharedPreferences
import com.bitwarden.core.WrappedAccountCryptographicState
import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow
import com.bitwarden.core.data.serializer.SafeMapSerializer
import com.bitwarden.core.data.util.decodeFromStringOrNull
@@ -11,6 +12,8 @@ import com.x8bit.bitwarden.data.auth.datasource.disk.model.AccountTokensJson
import com.x8bit.bitwarden.data.auth.datasource.disk.model.OnboardingStatus
import com.x8bit.bitwarden.data.auth.datasource.disk.model.PendingAuthRequestJson
import com.x8bit.bitwarden.data.auth.datasource.disk.model.UserStateJson
import com.x8bit.bitwarden.data.auth.datasource.disk.serializer.WrappedAccountCryptographicStateSerializer
import com.x8bit.bitwarden.data.auth.repository.util.toAccountCryptographicState
import com.x8bit.bitwarden.data.platform.datasource.disk.legacy.LegacySecureStorageMigrator
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
@@ -54,6 +57,7 @@ private const val ONBOARDING_STATUS_KEY = "onboardingStatus"
private const val SHOW_IMPORT_LOGINS_KEY = "showImportLogins"
private const val LAST_LOCK_TIMESTAMP = "lastLockTimestamp"
private const val PROFILE_ACCOUNT_KEYS_KEY = "profileAccountKeys"
private const val ACCOUNT_CRYPTOGRAPHIC_STATE_KEY = "accountCryptographicState"
/**
* Primary implementation of [AuthDiskSource].
@@ -91,6 +95,10 @@ class AuthDiskSourceImpl(
mutableMapOf<String, MutableSharedFlow<String?>>()
private val mutableUserStateFlow = bufferedMutableSharedFlow<UserStateJson?>(replay = 1)
private val wrappedAccountCryptographicStateSerializer by lazy {
WrappedAccountCryptographicStateSerializer()
}
override var userState: UserStateJson?
get() = getString(key = STATE_KEY)?.let { json.decodeFromStringOrNull(it) }
set(value) {
@@ -109,6 +117,14 @@ class AuthDiskSourceImpl(
// We must migrate the tokens from being stored in the UserState(shared preferences) to
// being stored separately in encrypted shared preferences.
migrateAccountTokens()
// We want to make sure that any left over encrypted user keys are scrubbed from storage
// Since it is no longer supported.
removeLegacyUserKeys()
// We must migrate the Private Key and Account Keys to use the Account Cryptographic state
// from now on.
migrateAccountKeys()
}
override var authenticatorSyncSymmetricKey: ByteArray?
@@ -146,11 +162,9 @@ class AuthDiskSourceImpl(
override fun clearData(userId: String) {
storeInvalidUnlockAttempts(userId = userId, invalidUnlockAttempts = null)
storeUserKey(userId = userId, userKey = null)
storeLocalUserDataKey(userId = userId, wrappedKey = null)
storeUserAutoUnlockKey(userId = userId, userAutoUnlockKey = null)
storePrivateKey(userId = userId, privateKey = null)
storeAccountKeys(userId = userId, accountKeys = null)
storeAccountCryptographicState(userId = userId, accountCryptographicState = null)
storeOrganizationKeys(userId = userId, organizationKeys = null)
storeOrganizations(userId = userId, organizations = null)
storeUserBiometricInitVector(userId = userId, iv = null)
@@ -231,16 +245,6 @@ class AuthDiskSourceImpl(
)
}
override fun getUserKey(userId: String): String? =
getString(key = MASTER_KEY_ENCRYPTION_USER_KEY.appendIdentifier(userId))
override fun storeUserKey(userId: String, userKey: String?) {
putString(
key = MASTER_KEY_ENCRYPTION_USER_KEY.appendIdentifier(userId),
value = userKey,
)
}
override fun getLocalUserDataKey(userId: String): String? =
getString(key = LOCAL_USER_DATA_KEY.appendIdentifier(userId))
@@ -248,29 +252,20 @@ class AuthDiskSourceImpl(
putString(key = LOCAL_USER_DATA_KEY.appendIdentifier(userId), value = wrappedKey)
}
@Deprecated("Use getAccountKeys instead.", replaceWith = ReplaceWith("getAccountKeys"))
override fun getPrivateKey(userId: String): String? =
getString(key = MASTER_KEY_ENCRYPTION_PRIVATE_KEY.appendIdentifier(userId))
override fun getAccountCryptographicState(userId: String): WrappedAccountCryptographicState? =
getEncryptedString(key = ACCOUNT_CRYPTOGRAPHIC_STATE_KEY.appendIdentifier(userId))?.let {
json.decodeFromStringOrNull(wrappedAccountCryptographicStateSerializer, it)
}
@Deprecated("Use storeAccountKeys instead.", replaceWith = ReplaceWith("storeAccountKeys"))
override fun storePrivateKey(userId: String, privateKey: String?) {
putString(
key = MASTER_KEY_ENCRYPTION_PRIVATE_KEY.appendIdentifier(userId),
value = privateKey,
)
}
override fun getAccountKeys(userId: String): AccountKeysJson? =
getEncryptedString(key = PROFILE_ACCOUNT_KEYS_KEY.appendIdentifier(userId))
?.let { json.decodeFromStringOrNull(it) }
override fun storeAccountKeys(
override fun storeAccountCryptographicState(
userId: String,
accountKeys: AccountKeysJson?,
accountCryptographicState: WrappedAccountCryptographicState?,
) {
putEncryptedString(
key = PROFILE_ACCOUNT_KEYS_KEY.appendIdentifier(userId),
value = accountKeys?.let { json.encodeToString(it) },
key = ACCOUNT_CRYPTOGRAPHIC_STATE_KEY.appendIdentifier(userId),
value = accountCryptographicState?.let {
json.encodeToString(wrappedAccountCryptographicStateSerializer, it)
},
)
}
@@ -662,4 +657,35 @@ class AuthDiskSourceImpl(
.orEmpty(),
)
}
private fun removeLegacyUserKeys() {
removeWithPrefix(prefix = MASTER_KEY_ENCRYPTION_USER_KEY)
}
private fun migrateAccountKeys() {
userState
?.accounts
.orEmpty()
.values
.forEach { account ->
val userId = account.profile.userId
val accountKeysKey = PROFILE_ACCOUNT_KEYS_KEY.appendIdentifier(userId)
val privateKeyKey = MASTER_KEY_ENCRYPTION_PRIVATE_KEY.appendIdentifier(userId)
val accountKeys = getEncryptedString(key = accountKeysKey)
?.let { json.decodeFromStringOrNull<AccountKeysJson>(it) }
val privateKey = accountKeys
?.publicKeyEncryptionKeyPair
?.wrappedPrivateKey
?: getString(key = privateKeyKey)
privateKey?.let {
storeAccountCryptographicState(
userId = userId,
accountCryptographicState = accountKeys.toAccountCryptographicState(it),
)
// Remove the Account Keys and Private Key
putEncryptedString(key = accountKeysKey, value = null)
putString(key = privateKeyKey, value = null)
}
}
}
}

View File

@@ -0,0 +1,85 @@
package com.x8bit.bitwarden.data.auth.datasource.disk.serializer
import com.bitwarden.core.WrappedAccountCryptographicState
import kotlinx.serialization.KSerializer
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
/**
* Custom [KSerializer] for [WrappedAccountCryptographicState].
*
* Encodes the sealed class with a `"type"` discriminator field:
* - `"v1"`: [WrappedAccountCryptographicState.V1] — wrapped private key only.
* - `"v2"`: [WrappedAccountCryptographicState.V2] — wrapped private key, signing key, signed
* public key, and signed security state.
*/
internal class WrappedAccountCryptographicStateSerializer :
KSerializer<WrappedAccountCryptographicState> {
private val surrogateSerializer = Surrogate.serializer()
override val descriptor: SerialDescriptor = surrogateSerializer.descriptor
override fun deserialize(decoder: Decoder): WrappedAccountCryptographicState =
when (val surrogate = decoder.decodeSerializableValue(surrogateSerializer)) {
is Surrogate.V1 -> {
WrappedAccountCryptographicState.V1(privateKey = surrogate.privateKey)
}
is Surrogate.V2 -> {
WrappedAccountCryptographicState.V2(
privateKey = surrogate.privateKey,
signingKey = surrogate.signingKey,
signedPublicKey = surrogate.signedPublicKey,
securityState = surrogate.securityState,
)
}
}
override fun serialize(encoder: Encoder, value: WrappedAccountCryptographicState) {
val surrogate = when (value) {
is WrappedAccountCryptographicState.V1 -> {
Surrogate.V1(privateKey = value.privateKey)
}
is WrappedAccountCryptographicState.V2 -> {
Surrogate.V2(
privateKey = value.privateKey,
signingKey = value.signingKey,
signedPublicKey = value.signedPublicKey,
securityState = value.securityState,
)
}
}
encoder.encodeSerializableValue(surrogateSerializer, surrogate)
}
}
@Serializable
private sealed class Surrogate {
@Serializable
@SerialName("v1")
data class V1(
@SerialName("privateKey")
val privateKey: String,
) : Surrogate()
@Serializable
@SerialName("v2")
data class V2(
@SerialName("privateKey")
val privateKey: String,
@SerialName("signingKey")
val signingKey: String,
@SerialName("signedPublicKey")
val signedPublicKey: String?,
@SerialName("securityState")
val securityState: String,
) : Surrogate()
}

View File

@@ -11,6 +11,9 @@ import com.bitwarden.core.RegisterKeyResponse
import com.bitwarden.core.RegisterTdeKeyResponse
import com.bitwarden.crypto.HashPurpose
import com.bitwarden.crypto.Kdf
import com.bitwarden.policies.OrganizationUserPolicyContext
import com.bitwarden.policies.PolicyType
import com.bitwarden.policies.PolicyView
import com.x8bit.bitwarden.data.auth.datasource.sdk.model.PasswordStrength
/**
@@ -134,4 +137,13 @@ interface AuthSdkSource {
passwordStrength: PasswordStrength,
policy: MasterPasswordPolicyOptions,
): Result<Boolean>
/**
* Applies the appropriate filters for determining what policies apply to the user.
*/
fun filterPolicies(
policies: List<PolicyView>,
organizations: List<OrganizationUserPolicyContext>,
policyType: PolicyType,
): Result<List<PolicyView>>
}

View File

@@ -13,14 +13,19 @@ import com.bitwarden.core.KeyConnectorResponse
import com.bitwarden.core.MasterPasswordPolicyOptions
import com.bitwarden.core.RegisterKeyResponse
import com.bitwarden.core.RegisterTdeKeyResponse
import com.bitwarden.core.data.manager.dispatcher.DispatcherManager
import com.bitwarden.crypto.HashPurpose
import com.bitwarden.crypto.Kdf
import com.bitwarden.policies.OrganizationUserPolicyContext
import com.bitwarden.policies.PolicyType
import com.bitwarden.policies.PolicyView
import com.bitwarden.sdk.AuthClient
import com.x8bit.bitwarden.data.auth.datasource.sdk.model.PasswordStrength
import com.x8bit.bitwarden.data.auth.datasource.sdk.util.toPasswordStrengthOrNull
import com.x8bit.bitwarden.data.auth.datasource.sdk.util.toUByte
import com.x8bit.bitwarden.data.platform.datasource.sdk.BaseSdkSource
import com.x8bit.bitwarden.data.platform.manager.SdkClientManager
import kotlinx.coroutines.withContext
/**
* Primary implementation of [AuthSdkSource] that serves as a convenience wrapper around a
@@ -28,6 +33,7 @@ import com.x8bit.bitwarden.data.platform.manager.SdkClientManager
*/
@Suppress("TooManyFunctions")
class AuthSdkSourceImpl(
private val dispatcherManager: DispatcherManager,
sdkClientManager: SdkClientManager,
) : BaseSdkSource(sdkClientManager = sdkClientManager),
AuthSdkSource {
@@ -42,10 +48,8 @@ class AuthSdkSourceImpl(
masterPasswordHint: String?,
shouldResetPasswordEnroll: Boolean,
): Result<JitMasterPasswordRegistrationResponse> = runCatchingWithLogs {
getClient(userId = userId)
.auth()
.registration()
.postKeysForJitPasswordRegistration(
withContext(context = dispatcherManager.io) {
getClient(userId = userId).auth().registration().postKeysForJitPasswordRegistration(
request = JitMasterPasswordRegistrationRequest(
orgId = organizationId,
orgPublicKey = organizationPublicKey,
@@ -57,6 +61,7 @@ class AuthSdkSourceImpl(
resetPasswordEnroll = shouldResetPasswordEnroll,
),
)
}
}
override suspend fun postKeysForKeyConnectorRegistration(
@@ -65,11 +70,13 @@ class AuthSdkSourceImpl(
keyConnectorUrl: String,
ssoOrganizationIdentifier: String,
): Result<KeyConnectorRegistrationResult> = runCatchingWithLogs {
useClient(userId = userId, accessToken = accessToken) {
auth().registration().postKeysForKeyConnectorRegistration(
keyConnectorUrl = keyConnectorUrl,
ssoOrgIdentifier = ssoOrganizationIdentifier,
)
withContext(context = dispatcherManager.io) {
useClient(userId = userId, accessToken = accessToken) {
auth().registration().postKeysForKeyConnectorRegistration(
keyConnectorUrl = keyConnectorUrl,
ssoOrgIdentifier = ssoOrganizationIdentifier,
)
}
}
}
@@ -80,10 +87,8 @@ class AuthSdkSourceImpl(
deviceIdentifier: String,
shouldTrustDevice: Boolean,
): Result<TdeRegistrationResponse> = runCatchingWithLogs {
getClient(userId = userId)
.auth()
.registration()
.postKeysForTdeRegistration(
withContext(context = dispatcherManager.io) {
getClient(userId = userId).auth().registration().postKeysForTdeRegistration(
request = TdeRegistrationRequest(
orgId = organizationId,
orgPublicKey = organizationPublicKey,
@@ -92,6 +97,7 @@ class AuthSdkSourceImpl(
trustDevice = shouldTrustDevice,
),
)
}
}
override suspend fun postKeysForUserPasswordRegistration(
@@ -101,23 +107,25 @@ class AuthSdkSourceImpl(
masterPasswordHint: String?,
emailVerificationToken: String,
): Result<UserMasterPasswordRegistrationResponse> = runCatchingWithLogs {
useClient {
auth().registration().postKeysForUserPasswordRegistration(
request = UserMasterPasswordRegistrationRequest(
email = email,
salt = salt,
masterPassword = masterPassword,
masterPasswordHint = masterPasswordHint,
emailVerificationToken = emailVerificationToken,
organizationUserId = null,
orgInviteToken = null,
orgSponsoredFreeFamilyPlanToken = null,
acceptEmergencyAccessInviteToken = null,
acceptEmergencyAccessId = null,
providerInviteToken = null,
providerUserId = null,
),
)
withContext(context = dispatcherManager.io) {
useClient {
auth().registration().postKeysForUserPasswordRegistration(
request = UserMasterPasswordRegistrationRequest(
email = email,
salt = salt,
masterPassword = masterPassword,
masterPasswordHint = masterPasswordHint,
emailVerificationToken = emailVerificationToken,
organizationUserId = null,
orgInviteToken = null,
orgSponsoredFreeFamilyPlanToken = null,
acceptEmergencyAccessInviteToken = null,
acceptEmergencyAccessId = null,
providerInviteToken = null,
providerUserId = null,
),
)
}
}
}
@@ -221,4 +229,16 @@ class AuthSdkSourceImpl(
)
}
}
override fun filterPolicies(
policies: List<PolicyView>,
organizations: List<OrganizationUserPolicyContext>,
policyType: PolicyType,
): Result<List<PolicyView>> = runCatchingWithLogs {
globalClient.policies().filterByType(
policies = policies,
organizationUserPolicyContexts = organizations,
policyType = policyType,
)
}
}

View File

@@ -1,5 +1,6 @@
package com.x8bit.bitwarden.data.auth.datasource.sdk.di
import com.bitwarden.core.data.manager.dispatcher.DispatcherManager
import com.x8bit.bitwarden.data.auth.datasource.sdk.AuthSdkSource
import com.x8bit.bitwarden.data.auth.datasource.sdk.AuthSdkSourceImpl
import com.x8bit.bitwarden.data.platform.manager.SdkClientManager
@@ -19,8 +20,10 @@ object AuthSdkModule {
@Provides
@Singleton
fun provideAuthSdkSource(
dispatcherManager: DispatcherManager,
sdkClientManager: SdkClientManager,
): AuthSdkSource = AuthSdkSourceImpl(
dispatcherManager = dispatcherManager,
sdkClientManager = sdkClientManager,
)
}

View File

@@ -3,7 +3,6 @@ package com.x8bit.bitwarden.data.auth.manager
import com.bitwarden.crypto.Kdf
import com.bitwarden.network.model.AccountKeysJson
import com.bitwarden.network.model.KdfTypeJson
import com.bitwarden.network.model.KeyConnectorMasterKeyResponseJson
import com.x8bit.bitwarden.data.auth.manager.model.MigrateExistingUserToKeyConnectorResult
import com.x8bit.bitwarden.data.auth.manager.model.MigrateNewUserToKeyConnectorResult
@@ -11,14 +10,6 @@ import com.x8bit.bitwarden.data.auth.manager.model.MigrateNewUserToKeyConnectorR
* Manager used to interface with a key connector.
*/
interface KeyConnectorManager {
/**
* Retrieves the master key from the key connector.
*/
suspend fun getMasterKeyFromKeyConnector(
url: String,
accessToken: String,
): Result<KeyConnectorMasterKeyResponseJson>
/**
* Migrates an existing user to use the key connector.
*/

View File

@@ -1,14 +1,12 @@
package com.x8bit.bitwarden.data.auth.manager
import com.bitwarden.core.WrappedAccountCryptographicState
import com.bitwarden.core.data.manager.dispatcher.DispatcherManager
import com.bitwarden.core.data.manager.model.FlagKey
import com.bitwarden.core.data.util.flatMap
import com.bitwarden.crypto.Kdf
import com.bitwarden.network.model.AccountKeysJson
import com.bitwarden.network.model.KdfTypeJson
import com.bitwarden.network.model.KeyConnectorKeyRequestJson
import com.bitwarden.network.model.KeyConnectorMasterKeyResponseJson
import com.bitwarden.network.service.AccountsService
import com.x8bit.bitwarden.data.auth.datasource.sdk.AuthSdkSource
import com.x8bit.bitwarden.data.auth.manager.model.MigrateExistingUserToKeyConnectorResult
@@ -17,7 +15,6 @@ import com.x8bit.bitwarden.data.auth.repository.util.toAccountCryptographicState
import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager
import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.DeriveKeyConnectorResult
import kotlinx.coroutines.withContext
/**
* The default implementation of the [KeyConnectorManager].
@@ -27,17 +24,7 @@ class KeyConnectorManagerImpl(
private val authSdkSource: AuthSdkSource,
private val vaultSdkSource: VaultSdkSource,
private val featureFlagManager: FeatureFlagManager,
private val dispatcherManager: DispatcherManager,
) : KeyConnectorManager {
override suspend fun getMasterKeyFromKeyConnector(
url: String,
accessToken: String,
): Result<KeyConnectorMasterKeyResponseJson> =
accountsService.getMasterKeyFromKeyConnector(
url = url,
accessToken = accessToken,
)
override suspend fun migrateExistingUserToKeyConnector(
userId: String,
url: String,
@@ -97,26 +84,24 @@ class KeyConnectorManagerImpl(
organizationIdentifier: String,
): Result<MigrateNewUserToKeyConnectorResult> =
if (featureFlagManager.getFeatureFlag(FlagKey.V2EncryptionKeyConnector)) {
withContext(dispatcherManager.io) {
authSdkSource
.postKeysForKeyConnectorRegistration(
userId = userId,
accessToken = accessToken,
keyConnectorUrl = url,
ssoOrganizationIdentifier = organizationIdentifier,
authSdkSource
.postKeysForKeyConnectorRegistration(
userId = userId,
accessToken = accessToken,
keyConnectorUrl = url,
ssoOrganizationIdentifier = organizationIdentifier,
)
.map {
MigrateNewUserToKeyConnectorResult(
masterKey = it.keyConnectorKey,
encryptedUserKey = it.keyConnectorKeyWrappedUserKey,
privateKey = when (val state = it.accountCryptographicState) {
is WrappedAccountCryptographicState.V1 -> state.privateKey
is WrappedAccountCryptographicState.V2 -> state.privateKey
},
accountCryptographicState = it.accountCryptographicState,
)
.map {
MigrateNewUserToKeyConnectorResult(
masterKey = it.keyConnectorKey,
encryptedUserKey = it.keyConnectorKeyWrappedUserKey,
privateKey = when (val state = it.accountCryptographicState) {
is WrappedAccountCryptographicState.V1 -> state.privateKey
is WrappedAccountCryptographicState.V2 -> state.privateKey
},
accountCryptographicState = it.accountCryptographicState,
)
}
}
}
} else {
legacyMigrateNewUserToKeyConnector(
accountKeys = accountKeys,

View File

@@ -1,8 +1,8 @@
package com.x8bit.bitwarden.data.auth.manager
import com.bitwarden.core.data.manager.dispatcher.DispatcherManager
import com.bitwarden.network.model.PolicyTypeJson
import com.bitwarden.network.model.SyncResponseJson
import com.bitwarden.policies.PolicyType
import com.bitwarden.policies.PolicyView
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
import com.x8bit.bitwarden.data.auth.datasource.disk.model.OnboardingStatus
import com.x8bit.bitwarden.data.auth.datasource.disk.model.UserStateJson
@@ -168,8 +168,8 @@ class UserStateManagerImpl(
private fun existingPolicies(
userId: String,
policyType: PolicyTypeJson,
): List<SyncResponseJson.Policy> = policyManager.getUserPolicies(
policyType: PolicyType,
): List<PolicyView> = policyManager.getUserPolicies(
userId = userId,
type = policyType,
)

View File

@@ -90,14 +90,12 @@ object AuthManagerModule {
authSdkSource: AuthSdkSource,
vaultSdkSource: VaultSdkSource,
featureFlagManager: FeatureFlagManager,
dispatcherManager: DispatcherManager,
): KeyConnectorManager =
KeyConnectorManagerImpl(
accountsService = accountsService,
authSdkSource = authSdkSource,
vaultSdkSource = vaultSdkSource,
featureFlagManager = featureFlagManager,
dispatcherManager = dispatcherManager,
)
@Provides

View File

@@ -2,6 +2,7 @@ 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
@@ -25,9 +26,9 @@ import com.bitwarden.network.model.GetTokenResponseJson
import com.bitwarden.network.model.IdentityTokenAuthModel
import com.bitwarden.network.model.OrganizationAutoEnrollStatusResponseJson
import com.bitwarden.network.model.OrganizationKeysResponseJson
import com.bitwarden.network.model.OrganizationStatusType
import com.bitwarden.network.model.OrganizationType
import com.bitwarden.network.model.PasswordHintResponseJson
import com.bitwarden.network.model.PolicyTypeJson
import com.bitwarden.network.model.PrevalidateSsoResponseJson
import com.bitwarden.network.model.RefreshTokenResponseJson
import com.bitwarden.network.model.RegisterFinishRequestJson
@@ -38,7 +39,6 @@ import com.bitwarden.network.model.ResetPasswordRequestJson
import com.bitwarden.network.model.SendVerificationEmailRequestJson
import com.bitwarden.network.model.SendVerificationEmailResponseJson
import com.bitwarden.network.model.SetPasswordRequestJson
import com.bitwarden.network.model.SyncResponseJson
import com.bitwarden.network.model.TrustedDeviceUserDecryptionOptionsJson
import com.bitwarden.network.model.TwoFactorAuthMethod
import com.bitwarden.network.model.TwoFactorDataModel
@@ -52,6 +52,8 @@ import com.bitwarden.network.service.HaveIBeenPwnedService
import com.bitwarden.network.service.IdentityService
import com.bitwarden.network.service.OrganizationService
import com.bitwarden.network.util.isSslHandShakeError
import com.bitwarden.policies.PolicyType
import com.bitwarden.policies.PolicyView
import com.bitwarden.ui.platform.resource.BitwardenString
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
import com.x8bit.bitwarden.data.auth.datasource.disk.model.AccountJson
@@ -103,12 +105,11 @@ 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.accountKeysJson
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.privateKey
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
@@ -132,6 +133,7 @@ import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockError
import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockResult
import com.x8bit.bitwarden.data.vault.repository.model.onVaultUnlockSuccess
import com.x8bit.bitwarden.data.vault.repository.util.toSdkMasterPasswordUnlock
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -152,7 +154,6 @@ import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.withContext
import timber.log.Timber
import java.time.Clock
import javax.inject.Singleton
@@ -189,7 +190,7 @@ class AuthRepositoryImpl(
private val featureFlagManager: FeatureFlagManager,
logsManager: LogsManager,
pushManager: PushManager,
private val dispatcherManager: DispatcherManager,
dispatcherManager: DispatcherManager,
) : AuthRepository,
AuthRequestManager by authRequestManager,
BiometricsEncryptionManager by biometricsEncryptionManager,
@@ -312,6 +313,7 @@ class AuthRepositoryImpl(
override val organizations: List<Organization>
get() = activeUserId
?.let { authDiskSource.getOrganizations(it) }
?.filter { it.status == OrganizationStatusType.CONFIRMED }
.orEmpty()
.toOrganizations()
@@ -366,7 +368,7 @@ class AuthRepositoryImpl(
// When the policies for the user have been set, complete the login process.
policyManager
.getActivePoliciesFlow(type = PolicyTypeJson.MASTER_PASSWORD)
.getActivePoliciesFlow(type = PolicyType.MASTER_PASSWORD)
.onEach { policies ->
val userId = activeUserId ?: return@onEach
@@ -534,17 +536,13 @@ class AuthRepositoryImpl(
.map { registerTdeKeyResponse to createAccountKeysResponse }
}
.onSuccess { (registerTdeKeyResponse, createAccountKeysResponse) ->
authDiskSource.storeAccountKeys(
authDiskSource.storeAccountCryptographicState(
userId = userId,
accountKeys = createAccountKeysResponse.accountKeys,
)
// TDE and SSO user creation still uses crypto-v1. These users are not expected to
// have the AEAD keys so we only store the private key for now.
// See https://github.com/bitwarden/android/pull/5682#discussion_r2273940332
// for more details.
authDiskSource.storePrivateKey(
userId = userId,
privateKey = registerTdeKeyResponse.privateKey,
accountCryptographicState = createAccountKeysResponse
.accountKeys
.toAccountCryptographicState(
privateKey = registerTdeKeyResponse.privateKey,
),
)
vaultRepository.syncVaultState(userId = userId)
registerTdeKeyResponse.deviceKey?.let { response ->
@@ -563,15 +561,14 @@ class AuthRepositoryImpl(
): Result<VaultUnlockResult> {
val userId = profile.userId
val shouldTrustDevice = authDiskSource.getShouldTrustDevice(userId = userId) == true
return withContext(dispatcherManager.io) {
authSdkSource.postKeysForTdeRegistration(
return authSdkSource
.postKeysForTdeRegistration(
userId = userId,
organizationId = orgAutoEnrollStatus.organizationId,
organizationPublicKey = orgKeys.publicKey,
deviceIdentifier = authDiskSource.uniqueAppId,
shouldTrustDevice = shouldTrustDevice,
)
}
.map { response ->
// Clear the 'should trust device' flag, since the SDK trusted the device above.
authDiskSource.storeShouldTrustDevice(userId = userId, shouldTrustDevice = null)
@@ -583,25 +580,16 @@ class AuthRepositoryImpl(
decryptedUserKey = response.userKey,
),
)
.also { result ->
if (result is VaultUnlockResult.Success) {
authDiskSource.storeAccountKeys(
.onVaultUnlockSuccess {
authDiskSource.storeAccountCryptographicState(
userId = userId,
accountCryptographicState = response.accountCryptographicState,
)
if (shouldTrustDevice) {
authDiskSource.storeDeviceKey(
userId = userId,
accountKeys = response.accountCryptographicState.accountKeysJson,
deviceKey = response.deviceKey,
)
// Storing the private key here for legacy purposes, the
// `accountKeysJson` stored above will be used for most purposes.
authDiskSource.storePrivateKey(
userId = userId,
privateKey = response.accountCryptographicState.privateKey,
)
if (shouldTrustDevice) {
authDiskSource.storeDeviceKey(
userId = userId,
deviceKey = response.deviceKey,
)
}
}
}
}
@@ -612,25 +600,18 @@ class AuthRepositoryImpl(
asymmetricalKey: String,
): LoginResult {
val profile = authDiskSource.userState?.activeAccount?.profile
?: return LoginResult.Error(errorMessage = null, error = NoActiveUserException())
?: return LoginResult.Error(error = NoActiveUserException())
val userId = profile.userId
val accountKeys = authDiskSource.getAccountKeys(userId = userId)
val privateKey = accountKeys?.publicKeyEncryptionKeyPair?.wrappedPrivateKey
?: authDiskSource.getPrivateKey(userId = userId)
?: return LoginResult.Error(
errorMessage = null,
error = MissingPropertyException("Private Key"),
)
val accountCryptographicState = authDiskSource
.getAccountCryptographicState(userId = userId)
?: return LoginResult.Error(MissingPropertyException("Account Cryptographic State"))
checkForVaultUnlockError(
onVaultUnlockError = { error ->
return error.toLoginErrorResult()
},
) {
unlockVault(
accountCryptographicState = accountKeys.toAccountCryptographicState(
privateKey = privateKey,
),
accountCryptographicState = accountCryptographicState,
accountProfile = profile,
initUserCryptoMethod = InitUserCryptoMethod.AuthRequest(
requestPrivateKey = requestPrivateKey,
@@ -670,7 +651,7 @@ class AuthRepositoryImpl(
onFailure = { throwable ->
when {
throwable.isSslHandShakeError() -> LoginResult.CertificateError
else -> LoginResult.Error(errorMessage = null, error = throwable)
else -> LoginResult.Error(error = throwable)
}
},
onSuccess = { it },
@@ -715,10 +696,7 @@ class AuthRepositoryImpl(
orgIdentifier = orgIdentifier,
)
}
?: LoginResult.Error(
errorMessage = null,
error = MissingPropertyException("Identity Token Auth Model"),
)
?: LoginResult.Error(error = MissingPropertyException("Identity Token Auth Model"))
override suspend fun login(
email: String,
@@ -736,17 +714,13 @@ class AuthRepositoryImpl(
orgIdentifier = orgIdentifier,
)
}
?: LoginResult.Error(
errorMessage = null,
error = MissingPropertyException("Identity Token Auth Model"),
)
?: LoginResult.Error(error = MissingPropertyException("Identity Token Auth Model"))
override suspend fun continueKeyConnectorLogin(
orgIdentifier: String,
email: String,
): LoginResult {
val response = keyConnectorResponse ?: return LoginResult.Error(
errorMessage = null,
error = MissingPropertyException("Key Connector Response"),
)
return handleLoginCommonSuccess(
@@ -974,15 +948,14 @@ class AuthRepositoryImpl(
return RegisterResult.WeakPassword
}
if (featureFlagManager.getFeatureFlag(key = FlagKey.V2EncryptionPassword)) {
return withContext(dispatcherManager.io) {
authSdkSource.postKeysForUserPasswordRegistration(
return authSdkSource
.postKeysForUserPasswordRegistration(
email = email,
salt = email,
masterPassword = masterPassword,
masterPasswordHint = masterPasswordHint,
emailVerificationToken = emailVerificationToken,
)
}
.fold(
onSuccess = { RegisterResult.Success },
onFailure = { RegisterResult.Error(errorMessage = null, error = it) },
@@ -1047,18 +1020,20 @@ class AuthRepositoryImpl(
}
override suspend fun removePassword(masterPassword: String): RemovePasswordResult {
val activeAccount = authDiskSource
val profile = authDiskSource
.userState
?.activeAccount
?.profile
?: return RemovePasswordResult.Error(error = NoActiveUserException())
val profile = activeAccount.profile
val userId = profile.userId
val userKey = authDiskSource
.getUserKey(userId = userId)
val userKey = profile
.userDecryptionOptions
?.masterPasswordUnlock
?.masterKeyWrappedUserKey
?: return RemovePasswordResult.Error(error = MissingPropertyException("User Key"))
val keyConnectorUrl = organizations
.find {
it.shouldUseKeyConnector &&
it.isKeyConnectorEnabled &&
it.role != OrganizationType.OWNER &&
it.role != OrganizationType.ADMIN
}
@@ -1105,16 +1080,14 @@ class AuthRepositoryImpl(
newPassword: String,
passwordHint: String?,
): ResetPasswordResult {
val activeAccount = authDiskSource
.userState
?.activeAccount
val profile = authDiskSource.userState?.activeAccount?.profile
?: return ResetPasswordResult.Error(error = NoActiveUserException())
val currentPasswordHash = currentPassword?.let { password ->
authSdkSource
.hashPassword(
email = activeAccount.profile.email,
email = profile.email,
password = password,
kdf = activeAccount.profile.toSdkParams(),
kdf = profile.toSdkParams(),
purpose = HashPurpose.SERVER_AUTHORIZATION,
)
.fold(
@@ -1122,48 +1095,32 @@ class AuthRepositoryImpl(
onSuccess = { it },
)
}
val userId = activeAccount.profile.userId
val userId = profile.userId
return vaultSdkSource
.updatePassword(
userId = userId,
newPassword = newPassword,
)
.flatMap { updatePasswordResponse ->
accountsService
.resetPassword(
body = ResetPasswordRequestJson(
currentPasswordHash = currentPasswordHash,
newPasswordHash = updatePasswordResponse.passwordHash,
passwordHint = passwordHint,
key = updatePasswordResponse.newKey,
),
)
.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)
}
.fold(
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
},
onSuccess = { ResetPasswordResult.Success },
onFailure = { ResetPasswordResult.Error(error = it) },
)
}
@@ -1172,10 +1129,10 @@ class AuthRepositoryImpl(
organizationIdentifier: String,
password: String,
passwordHint: String?,
): SetPasswordResult {
): SetPasswordResult = userStateManager.userStateTransaction {
val profile = authDiskSource.userState?.activeAccount?.profile
?: return SetPasswordResult.Error(error = NoActiveUserException())
return when (profile.forcePasswordResetReason) {
?: return@userStateTransaction SetPasswordResult.Error(error = NoActiveUserException())
return@userStateTransaction when (profile.forcePasswordResetReason) {
ForcePasswordResetReason.TDE_USER_WITHOUT_PASSWORD_HAS_PASSWORD_RESET_PERMISSION -> {
setUpdatedPassword(
profile = profile,
@@ -1211,45 +1168,34 @@ class AuthRepositoryImpl(
.flatMap { response ->
accountsService
.setPassword(
body = SetPasswordRequestJson(
passwordHash = response.passwordHash,
body = SetPasswordRequestJson.V2(
passwordHint = passwordHint,
organizationIdentifier = organizationIdentifier,
kdfIterations = profile.kdfIterations,
kdfMemory = profile.kdfMemory,
kdfParallelism = profile.kdfParallelism,
kdfType = profile.kdfType,
key = response.newKey,
keys = null,
kdf = profile.toKdfRequestModel(),
salt = profile.email,
masterPasswordAuthenticationHash = response.passwordHash,
masterKeyWrappedUserKey = response.newKey,
),
)
.onSuccess {
authDiskSource.storeUserKey(userId = userId, userKey = response.newKey)
}
.map { response.passwordHash }
.map { 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 {
.onSuccess { response ->
authDiskSource.userState = authDiskSource.userState?.toUserStateJsonWithPassword(
masterPasswordUnlock = null,
masterPasswordUnlock = MasterPasswordUnlockData(
kdf = profile.toSdkParams(),
masterKeyWrappedUserKey = response.newKey,
salt = profile.email,
),
)
this.organizationIdentifier = null
}
.flatMap { response ->
enrollUserInPasswordReset(
userId = userId,
organizationIdentifier = organizationIdentifier,
passwordHash = response.passwordHash,
)
}
.fold(
onFailure = { SetPasswordResult.Error(error = it) },
onSuccess = { SetPasswordResult.Success },
@@ -1279,31 +1225,21 @@ class AuthRepositoryImpl(
.map { orgKeys -> enrollStatus to orgKeys }
}
.flatMap { (enrollStatus, orgKeys) ->
withContext(dispatcherManager.io) {
authSdkSource.postKeysForJitPasswordRegistration(
userId = userId,
organizationId = enrollStatus.organizationId,
organizationPublicKey = orgKeys.publicKey,
organizationSsoIdentifier = organizationIdentifier,
salt = profile.email,
masterPassword = password,
masterPasswordHint = passwordHint,
shouldResetPasswordEnroll = enrollStatus.isResetPasswordEnabled,
)
}
authSdkSource.postKeysForJitPasswordRegistration(
userId = userId,
organizationId = enrollStatus.organizationId,
organizationPublicKey = orgKeys.publicKey,
organizationSsoIdentifier = organizationIdentifier,
salt = profile.email,
masterPassword = password,
masterPasswordHint = passwordHint,
shouldResetPasswordEnroll = enrollStatus.isResetPasswordEnabled,
)
}
.onSuccess { response ->
authDiskSource.storeAccountKeys(
authDiskSource.storeAccountCryptographicState(
userId = userId,
accountKeys = response.accountCryptographicState.accountKeysJson,
)
// TDE and SSO user creation still uses crypto-v1. These users are not
// expected to have the AEAD keys so we only store the private key for now.
// See https://github.com/bitwarden/android/pull/5682#discussion_r2273940332
// for more details.
authDiskSource.storePrivateKey(
userId = userId,
privateKey = response.accountCryptographicState.privateKey,
accountCryptographicState = response.accountCryptographicState,
)
authDiskSource.userState = authDiskSource.userState?.toUserStateJsonWithPassword(
masterPasswordUnlock = response.masterPasswordUnlock,
@@ -1344,7 +1280,7 @@ class AuthRepositoryImpl(
.flatMap { response ->
accountsService
.setPassword(
body = SetPasswordRequestJson(
body = SetPasswordRequestJson.V1(
passwordHash = response.masterPasswordHash,
passwordHint = passwordHint,
organizationIdentifier = organizationIdentifier,
@@ -1353,34 +1289,39 @@ class AuthRepositoryImpl(
kdfParallelism = profile.kdfParallelism,
kdfType = profile.kdfType,
key = response.encryptedUserKey,
keys = SetPasswordRequestJson.Keys(
keys = SetPasswordRequestJson.V1.Keys(
publicKey = response.keys.public,
encryptedPrivateKey = response.keys.private,
),
),
)
.onSuccess {
// This process is used by TDE and Enterprise accounts during initial
// login. We continue to store the locally generated keys
// until TDE and Enterprise accounts support AEAD keys.
authDiskSource.storePrivateKey(
authDiskSource.storeAccountCryptographicState(
userId = userId,
privateKey = response.keys.private,
)
authDiskSource.storeUserKey(
userId = userId,
userKey = response.encryptedUserKey,
accountCryptographicState = WrappedAccountCryptographicState.V1(
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.masterPasswordHash }
.map { response }
}
.flatMap { masterPasswordHash ->
.flatMap { response ->
when (val result = vaultRepository.unlockVaultWithMasterPassword(password)) {
is VaultUnlockResult.Success -> {
enrollUserInPasswordReset(
userId = userId,
organizationIdentifier = organizationIdentifier,
passwordHash = masterPasswordHash,
passwordHash = response.masterPasswordHash,
)
}
@@ -1390,12 +1331,6 @@ class AuthRepositoryImpl(
}
}
}
.onSuccess {
authDiskSource.userState = authDiskSource.userState?.toUserStateJsonWithPassword(
masterPasswordUnlock = null,
)
this.organizationIdentifier = null
}
.fold(
onFailure = { SetPasswordResult.Error(error = it) },
onSuccess = { SetPasswordResult.Success },
@@ -1516,7 +1451,12 @@ class AuthRepositoryImpl(
)
override suspend fun validatePassword(password: String): ValidatePasswordResult {
val userId = activeUserId ?: return ValidatePasswordResult.Error(NoActiveUserException())
val profile = authDiskSource
.userState
?.activeAccount
?.profile
?: return ValidatePasswordResult.Error(error = NoActiveUserException())
val userId = profile.userId
return authDiskSource
.getMasterPasswordHash(userId = userId)
?.let { masterPasswordHash ->
@@ -1532,8 +1472,10 @@ class AuthRepositoryImpl(
)
}
?: run {
val encryptedKey = authDiskSource
.getUserKey(userId)
val encryptedKey = profile
.userDecryptionOptions
?.masterPasswordUnlock
?.masterKeyWrappedUserKey
?: return ValidatePasswordResult.Error(MissingPropertyException("UserKey"))
vaultSdkSource
.validatePasswordUserKey(
@@ -1551,8 +1493,8 @@ class AuthRepositoryImpl(
onSuccess = { ValidatePasswordResult.Success(isValid = true) },
onFailure = {
// We currently assume that all errors are caused by the user entering
// an invalid password, this is not necessarily the case but we have no
// way to differentiate between the different errors.
// an invalid password, this is not necessarily the case, but we have
// no way to differentiate between the different errors.
ValidatePasswordResult.Success(isValid = false)
},
)
@@ -1706,7 +1648,7 @@ class AuthRepositoryImpl(
*/
private suspend fun passwordPassesPolicies(
password: String,
policies: List<SyncResponseJson.Policy>,
policies: List<PolicyView>,
): Boolean {
// If there are no master password policies that are enabled and should be
// enforced on login, the check should complete.
@@ -1816,10 +1758,7 @@ class AuthRepositoryImpl(
LoginResult.UnofficialServerError
}
else -> LoginResult.Error(
errorMessage = null,
error = throwable,
)
else -> LoginResult.Error(error = throwable)
}
},
onSuccess = { loginResponse ->
@@ -1883,6 +1822,14 @@ class AuthRepositoryImpl(
)
val profile = userStateJson.activeAccount.profile
val userId = profile.userId
authDiskSource.storeAccountTokens(
userId = userId,
accountTokens = AccountTokensJson(
accessToken = loginResponse.accessToken,
refreshToken = loginResponse.refreshToken,
expiresAtSec = clock.instant().epochSecond + loginResponse.expiresInSeconds,
),
)
checkForVaultUnlockError(
onVaultUnlockError = { vaultUnlockError ->
@@ -1915,6 +1862,7 @@ class AuthRepositoryImpl(
// If a new KeyConnector user is logging in for the first time,
// we should ask him to confirm the domain
if (isNewKeyConnectorUser && isNotConfirmed) {
authDiskSource.storeAccountTokens(userId = profile.userId, accountTokens = null)
keyConnectorResponse = loginResponse
return@userStateTransaction LoginResult.ConfirmKeyConnectorDomain(
domain = keyConnectorUrl,
@@ -1956,16 +1904,7 @@ class AuthRepositoryImpl(
passwordsToCheckMap.put(userId, it)
}
authDiskSource.storeAccountTokens(
userId = userId,
accountTokens = AccountTokensJson(
accessToken = loginResponse.accessToken,
refreshToken = loginResponse.refreshToken,
expiresAtSec = clock.instant().epochSecond + loginResponse.expiresInSeconds,
),
)
settingsRepository.hasUserLoggedInOrCreatedAccount = true
authDiskSource.userState = userStateJson
password?.let {
// Automatically update kdf to minimums after password unlock and userState update
@@ -1977,22 +1916,16 @@ class AuthRepositoryImpl(
}
}
}
loginResponse.key?.let {
// Only set the value if it's present, since we may have set it already
// when we completed the pending admin auth request.
authDiskSource.storeUserKey(userId = userId, userKey = it)
}
// We continue to store the private key for backwards compatibility. Key connector
// conversion still relies on the private key.
loginResponse.privateKeyOrNull()?.let {
// Only set the value if it's present, since we may have set it already
// when we completed the key connector conversion.
authDiskSource.storePrivateKey(userId = userId, privateKey = it)
}
loginResponse.accountKeys?.let {
// Only set the value if it's present, since we may have set it already
// when we completed the key connector conversion.
authDiskSource.storeAccountKeys(userId = userId, accountKeys = it)
loginResponse.privateKeyOrNull()?.let { privateKey ->
// Only set the value if the private key is present, since we may have set
// the value already when we completed the key connector conversion.
authDiskSource.storeAccountCryptographicState(
userId = userId,
accountCryptographicState = loginResponse.accountKeys.toAccountCryptographicState(
privateKey = privateKey,
),
)
}
// If the user just authenticated with a two-factor code and selected the option to
// remember it, then the API response will return a token that will be used in place
@@ -2081,28 +2014,16 @@ class AuthRepositoryImpl(
null
} else if (key != null && privateKey != null) {
// This is a returning user who should already have the key connector setup
keyConnectorManager
.getMasterKeyFromKeyConnector(
unlockVault(
accountCryptographicState = loginResponse
.accountKeys
.toAccountCryptographicState(privateKey = privateKey),
accountProfile = profile,
initUserCryptoMethod = InitUserCryptoMethod.KeyConnectorUrl(
url = keyConnectorUrl,
accessToken = loginResponse.accessToken,
)
.map {
unlockVault(
accountCryptographicState = loginResponse
.accountKeys
.toAccountCryptographicState(privateKey = privateKey),
accountProfile = profile,
initUserCryptoMethod = InitUserCryptoMethod.KeyConnector(
masterKey = it.masterKey,
userKey = key,
),
)
}
.fold(
// If the request failed, we want to abort the login process
onFailure = { VaultUnlockResult.GenericError(error = it) },
onSuccess = { it },
)
keyConnectorKeyWrappedUserKey = key,
),
)
} else {
// This is a new user who needs to set up the key connector
val userId = profile.userId
@@ -2119,35 +2040,21 @@ class AuthRepositoryImpl(
organizationIdentifier = orgIdentifier,
)
.map { keyConnector ->
val accountCryptographicState = keyConnector.accountCryptographicState
this
.unlockVault(
accountCryptographicState = keyConnector.accountCryptographicState,
accountCryptographicState = accountCryptographicState,
accountProfile = profile,
initUserCryptoMethod = InitUserCryptoMethod.KeyConnector(
masterKey = keyConnector.masterKey,
userKey = keyConnector.encryptedUserKey,
),
)
.also { result ->
if (result is VaultUnlockResult.Success) {
// We now know that login/unlock was successful, so we store the
// userKey and privateKey we now have since it didn't exist on the
// loginResponse.
authDiskSource.storeUserKey(
userId = userId,
userKey = keyConnector.encryptedUserKey,
)
// We continue to store the private key for backwards compatibility
// since key connector conversion still relies on the private key.
authDiskSource.storePrivateKey(
userId = userId,
privateKey = keyConnector.privateKey,
)
authDiskSource.storeAccountKeys(
userId = userId,
accountKeys = loginResponse.accountKeys,
)
}
.onVaultUnlockSuccess {
authDiskSource.storeAccountCryptographicState(
userId = userId,
accountCryptographicState = accountCryptographicState,
)
}
}
.fold(
@@ -2222,8 +2129,8 @@ class AuthRepositoryImpl(
),
)
// We are purposely not storing the master password hash here since it is not
// formatted in in a manner that we can use. We will store it properly the next
// time the user enters their master password and it is validated.
// formatted in a manner that we can use. We will store it properly the next
// time the user enters their master password, and it is validated.
}
}
@@ -2277,7 +2184,6 @@ class AuthRepositoryImpl(
method = AuthRequestMethod.UserKey(protectedUserKey = userKey),
),
)
authDiskSource.storeUserKey(userId = userId, userKey = userKey)
}
authDiskSource.storePendingAuthRequest(
userId = userId,
@@ -2307,10 +2213,6 @@ class AuthRepositoryImpl(
deviceProtectedUserKey = encryptedUserKey,
),
)
if (vaultUnlockResult is VaultUnlockResult.Success) {
authDiskSource.storeUserKey(userId = userId, userKey = encryptedUserKey)
}
return vaultUnlockResult
}
@@ -2333,7 +2235,7 @@ class AuthRepositoryImpl(
// unlock the vault for organization data after receiving the sync response if this
// data is currently absent. These keys may be present during certain multi-phase login
// processes or if we needed to delete the user's token due to an encrypted data
// corruption issue and they are forced to log back in.
// corruption issue, and they are forced to log back in.
organizationKeys = authDiskSource.getOrganizationKeys(userId = userId),
)
}

View File

@@ -1,5 +1,7 @@
package com.x8bit.bitwarden.data.auth.repository.model
import com.x8bit.bitwarden.data.platform.util.userFriendlyMessage
/**
* Models result of logging in.
*/
@@ -30,8 +32,8 @@ sealed class LoginResult {
* There was an error logging in.
*/
data class Error(
val errorMessage: String?,
val error: Throwable?,
val errorMessage: String? = error?.userFriendlyMessage,
) : LoginResult()
/**

View File

@@ -15,5 +15,5 @@ fun VaultUnlockError.toLoginErrorResult(): LoginResult.Error = when (this) {
is VaultUnlockResult.BiometricDecodingError,
is VaultUnlockResult.GenericError,
is VaultUnlockResult.InvalidStateError,
-> LoginResult.Error(errorMessage = null, error = this.error)
-> LoginResult.Error(error = this.error)
}

View File

@@ -9,7 +9,7 @@ import com.bitwarden.network.model.OrganizationType
* @property name The name of the organization (if applicable).
* @property shouldManageResetPassword Indicates that this user has the permission to manage their
* own password.
* @property shouldUseKeyConnector Indicates that the organization uses a key connector.
* @property isKeyConnectorEnabled Indicates that the organization uses a key connector.
* @property role The user's role in the organization.
* @property keyConnectorUrl The key connector domain (if applicable).
* @property userIsClaimedByOrganization Indicates that the user is claimed by the organization.
@@ -20,7 +20,7 @@ data class Organization(
val id: String,
val name: String,
val shouldManageResetPassword: Boolean,
val shouldUseKeyConnector: Boolean,
val isKeyConnectorEnabled: Boolean,
val role: OrganizationType,
val keyConnectorUrl: String?,
val userIsClaimedByOrganization: Boolean,

View File

@@ -1,6 +1,7 @@
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
@@ -13,8 +14,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(
@@ -24,3 +25,23 @@ fun AccountJson.Profile.toSdkParams(): Kdf {
else -> Kdf.Pbkdf2(iterations = KdfParamsConstants.DEFAULT_PBKDF2_ITERATIONS.toUInt())
}
}
/**
* Convert [AccountJson.Profile] to [KdfJson] params for use with Bitwarden network requests.
*/
fun AccountJson.Profile.toKdfRequestModel(): KdfJson =
when (val kdfType = this.kdfType ?: KdfTypeJson.PBKDF2_SHA256) {
KdfTypeJson.ARGON2_ID -> KdfJson(
kdfType = kdfType,
iterations = this.kdfIterations ?: KdfParamsConstants.DEFAULT_ARGON2_ITERATIONS,
memory = this.kdfMemory ?: KdfParamsConstants.DEFAULT_ARGON2_MEMORY,
parallelism = this.kdfParallelism ?: KdfParamsConstants.DEFAULT_ARGON2_PARALLELISM,
)
KdfTypeJson.PBKDF2_SHA256 -> KdfJson(
kdfType = kdfType,
iterations = this.kdfIterations ?: KdfParamsConstants.DEFAULT_PBKDF2_ITERATIONS,
memory = this.kdfMemory,
parallelism = this.kdfParallelism,
)
}

View File

@@ -2,7 +2,6 @@ package com.x8bit.bitwarden.data.auth.repository.util
import com.bitwarden.core.WrappedAccountCryptographicState
import com.bitwarden.network.model.AccountKeysJson
import com.x8bit.bitwarden.data.vault.repository.util.createWrappedAccountCryptographicState
/**
* Creates a [WrappedAccountCryptographicState] based on the available cryptographic parameters.
@@ -15,9 +14,20 @@ import com.x8bit.bitwarden.data.vault.repository.util.createWrappedAccountCrypto
*/
fun AccountKeysJson?.toAccountCryptographicState(
privateKey: String,
): WrappedAccountCryptographicState = createWrappedAccountCryptographicState(
privateKey = privateKey,
securityState = this?.securityState?.securityState,
signingKey = this?.signatureKeyPair?.wrappedSigningKey,
signedPublicKey = this?.publicKeyEncryptionKeyPair?.signedPublicKey,
)
): WrappedAccountCryptographicState {
val securityState = this?.securityState?.securityState
val signingKey = this?.signatureKeyPair?.wrappedSigningKey
val signedPublicKey = this?.publicKeyEncryptionKeyPair?.signedPublicKey
return if (signingKey != null && securityState != null && signedPublicKey != null) {
WrappedAccountCryptographicState.V2(
privateKey = privateKey,
securityState = securityState,
signingKey = signingKey,
signedPublicKey = signedPublicKey,
)
} else {
WrappedAccountCryptographicState.V1(
privateKey = privateKey,
)
}
}

View File

@@ -1,5 +1,6 @@
package com.x8bit.bitwarden.data.auth.repository.util
import com.bitwarden.network.model.OrganizationStatusType
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
import com.x8bit.bitwarden.data.auth.datasource.disk.model.OnboardingStatus
import com.x8bit.bitwarden.data.auth.repository.model.UserAccountTokens
@@ -27,6 +28,7 @@ val AuthDiskSource.userOrganizationsList: List<UserOrganizations>
userId = userId,
organizations = this
.getOrganizations(userId = userId)
?.filter { it.status == OrganizationStatusType.CONFIRMED }
.orEmpty()
.toOrganizations(),
)
@@ -48,10 +50,15 @@ val AuthDiskSource.userOrganizationsListFlow: Flow<List<UserOrganizations>>
.map { (userId, _) ->
this
.getOrganizationsFlow(userId = userId)
.map {
.map { organizations ->
UserOrganizations(
userId = userId,
organizations = it.orEmpty().toOrganizations(),
organizations = organizations
?.filter {
it.status == OrganizationStatusType.CONFIRMED
}
.orEmpty()
.toOrganizations(),
)
}
},

View File

@@ -1,11 +1,24 @@
package com.x8bit.bitwarden.data.auth.repository.util
import com.bitwarden.core.data.util.decodeFromStringOrNull
import com.bitwarden.network.model.PolicyTypeJson
import com.bitwarden.network.model.MemberDecryptionType
import com.bitwarden.network.model.OrganizationStatusType
import com.bitwarden.network.model.OrganizationType
import com.bitwarden.network.model.ProductTierType
import com.bitwarden.network.model.ProviderType
import com.bitwarden.network.model.SyncResponseJson
import com.bitwarden.organizations.OrganizationUserStatusType
import com.bitwarden.organizations.OrganizationUserType
import com.bitwarden.organizations.Permissions
import com.bitwarden.organizations.ProfileOrganization
import com.bitwarden.policies.PolicyType
import com.bitwarden.policies.PolicyView
import com.x8bit.bitwarden.data.auth.repository.model.Organization
import com.x8bit.bitwarden.data.auth.repository.model.PolicyInformation
import kotlinx.serialization.json.Json
import com.bitwarden.organizations.MemberDecryptionType as SdkMemberDecryptionType
import com.bitwarden.organizations.ProductTierType as SdkProductTierType
import com.bitwarden.organizations.ProviderType as SdkProviderType
private val JSON = Json {
ignoreUnknownKeys = true
@@ -21,7 +34,7 @@ fun SyncResponseJson.Profile.Organization.toOrganization(): Organization? =
Organization(
id = this.id,
name = it,
shouldUseKeyConnector = this.shouldUseKeyConnector,
isKeyConnectorEnabled = this.isKeyConnectorEnabled,
role = this.type,
shouldManageResetPassword = this.permissions.shouldManageResetPassword,
keyConnectorUrl = this.keyConnectorUrl,
@@ -39,28 +52,164 @@ fun List<SyncResponseJson.Profile.Organization>.toOrganizations(): List<Organiza
this.mapNotNull { it.toOrganization() }
/**
* Convert the JSON data of the [SyncResponseJson.Policy] object into [PolicyInformation] data.
* Maps the given list of [SyncResponseJson.Profile.Organization] to a list of
* [ProfileOrganization]s.
*/
val SyncResponseJson.Policy.policyInformation: PolicyInformation?
get() = data?.toString()?.let {
@Suppress("MaxLineLength")
fun List<SyncResponseJson.Profile.Organization>.toSdkProfileOrganizations(): List<ProfileOrganization> =
this.mapNotNull { it.toSdkProfileOrganization() }
/**
* Maps the given [SyncResponseJson.Profile.Organization] to a [ProfileOrganization] or `null` if
* the [SyncResponseJson.Profile.Organization.name] is not present.
*/
@Suppress("LongMethod")
private fun SyncResponseJson.Profile.Organization.toSdkProfileOrganization(): ProfileOrganization? =
this.name?.let {
ProfileOrganization(
id = this.id,
name = it,
status = this.status.toSdkOrganizationUserStatusType(),
type = this.type.toSdkOrganizationUserType(),
enabled = this.isEnabled,
usePolicies = this.shouldUsePolicies,
useGroups = this.shouldUseGroups,
useDirectory = this.shouldUseDirectory,
useEvents = this.shouldUseEvents,
useTotp = this.shouldUseTotp,
use2fa = this.use2fa,
useApi = this.shouldUseApi,
useSso = this.useSso,
useOrganizationDomains = this.useOrganizationDomains,
useKeyConnector = this.shouldUseKeyConnector,
useScim = this.useScim,
useCustomPermissions = this.useCustomPermissions,
useResetPassword = this.useResetPassword,
useSecretsManager = this.useSecretsManager,
usePasswordManager = this.usePasswordManager,
useActivateAutofillPolicy = this.useActivateAutofillPolicy,
useAutomaticUserConfirmation = this.useAutomaticUserConfirmation,
selfHost = this.isSelfHost,
usersGetPremium = this.shouldUsersGetPremium,
seats = this.seats,
maxCollections = this.maxCollections,
maxStorageGb = this.maxStorageGb,
ssoBound = this.ssoBound,
identifier = this.identifier,
permissions = this.permissions.toSdkPermissions(),
resetPasswordEnrolled = this.resetPasswordEnrolled,
userId = this.userId,
organizationUserId = this.organizationUserId,
hasPublicAndPrivateKeys = this.hasPublicAndPrivateKeys,
providerId = this.providerId,
providerName = this.providerName,
providerType = this.providerType?.toSdkProviderType(),
isProviderUser = this.isProviderUser,
isMember = this.isMember,
familySponsorshipFriendlyName = this.familySponsorshipFriendlyName,
familySponsorshipAvailable = this.familySponsorshipAvailable,
productTierType = this.productTierType.toSdkProductTierType(),
keyConnectorEnabled = this.isKeyConnectorEnabled,
keyConnectorUrl = this.keyConnectorUrl,
familySponsorshipLastSyncDate = this.familySponsorshipLastSyncDate,
familySponsorshipValidUntil = this.familySponsorshipValidUntil,
familySponsorshipToDelete = this.familySponsorshipToDelete,
accessSecretsManager = this.accessSecretsManager,
limitCollectionCreation = this.limitCollectionCreation,
limitCollectionDeletion = this.limitCollectionDeletion,
limitItemDeletion = this.limitItemDeletion,
allowAdminAccessToAllCollectionItems = this.allowAdminAccessToAllCollectionItems,
userIsManagedByOrganization = this.userIsClaimedByOrganization,
useAccessIntelligence = this.useAccessIntelligence,
useAdminSponsoredFamilies = this.useAdminSponsoredFamilies,
useDisableSmAdsForUsers = this.useDisableSmAdsForUsers,
isAdminInitiated = this.isAdminInitiated,
ssoEnabled = this.ssoEnabled,
ssoMemberDecryptionType = this.ssoMemberDecryptionType?.toSdkMemberDecryptionType(),
usePhishingBlocker = this.usePhishingBlocker,
useMyItems = this.useMyItems,
)
}
/**
* Convert the JSON data of the [PolicyView] object into [PolicyInformation] data.
*/
val PolicyView.policyInformation: PolicyInformation?
get() = data?.let {
when (type) {
PolicyTypeJson.MASTER_PASSWORD -> {
PolicyType.MASTER_PASSWORD -> {
JSON.decodeFromStringOrNull<PolicyInformation.MasterPassword>(it)
}
PolicyTypeJson.PASSWORD_GENERATOR -> {
PolicyType.PASSWORD_GENERATOR -> {
JSON.decodeFromStringOrNull<PolicyInformation.PasswordGenerator>(it)
}
PolicyTypeJson.MAXIMUM_VAULT_TIMEOUT -> {
PolicyType.MAXIMUM_VAULT_TIMEOUT -> {
JSON.decodeFromStringOrNull<PolicyInformation.VaultTimeout>(it)
}
PolicyTypeJson.SEND_OPTIONS -> {
PolicyType.SEND_OPTIONS -> {
JSON.decodeFromStringOrNull<PolicyInformation.SendOptions>(it)
}
else -> null
}
}
private fun SyncResponseJson.Profile.Permissions.toSdkPermissions(): Permissions =
Permissions(
accessEventLogs = this.accessEventLogs,
accessImportExport = this.accessImportExport,
accessReports = this.accessReports,
createNewCollections = this.createNewCollections,
editAnyCollection = this.editAnyCollection,
deleteAnyCollection = this.deleteAnyCollection,
manageGroups = this.manageGroups,
manageSso = this.manageSso,
managePolicies = this.shouldManagePolicies,
manageUsers = this.manageUsers,
manageResetPassword = this.shouldManageResetPassword,
manageScim = this.manageScim,
)
private fun OrganizationStatusType.toSdkOrganizationUserStatusType(): OrganizationUserStatusType =
when (this) {
OrganizationStatusType.REVOKED -> OrganizationUserStatusType.REVOKED
OrganizationStatusType.INVITED -> OrganizationUserStatusType.INVITED
OrganizationStatusType.ACCEPTED -> OrganizationUserStatusType.ACCEPTED
OrganizationStatusType.CONFIRMED -> OrganizationUserStatusType.CONFIRMED
}
private fun OrganizationType.toSdkOrganizationUserType(): OrganizationUserType =
when (this) {
OrganizationType.OWNER -> OrganizationUserType.OWNER
OrganizationType.ADMIN -> OrganizationUserType.ADMIN
OrganizationType.USER -> OrganizationUserType.USER
OrganizationType.CUSTOM -> OrganizationUserType.CUSTOM
}
private fun ProviderType.toSdkProviderType(): SdkProviderType =
when (this) {
ProviderType.MSP -> SdkProviderType.MSP
ProviderType.RESELLER -> SdkProviderType.RESELLER
ProviderType.BUSINESS_UNIT -> SdkProviderType.BUSINESS_UNIT
}
private fun ProductTierType.toSdkProductTierType(): SdkProductTierType =
when (this) {
ProductTierType.FREE -> SdkProductTierType.FREE
ProductTierType.FAMILIES -> SdkProductTierType.FAMILIES
ProductTierType.TEAMS -> SdkProductTierType.TEAMS
ProductTierType.ENTERPRISE -> SdkProductTierType.ENTERPRISE
ProductTierType.TEAMS_STARTER -> SdkProductTierType.TEAMS_STARTER
}
private fun MemberDecryptionType.toSdkMemberDecryptionType(): SdkMemberDecryptionType =
when (this) {
MemberDecryptionType.MASTER_PASSWORD -> SdkMemberDecryptionType.MASTER_PASSWORD
MemberDecryptionType.KEY_CONNECTOR -> SdkMemberDecryptionType.KEY_CONNECTOR
MemberDecryptionType.TRUSTED_DEVICE_ENCRYPTION -> {
SdkMemberDecryptionType.TRUSTED_DEVICE_ENCRYPTION
}
}

View File

@@ -5,10 +5,12 @@ import com.bitwarden.data.repository.util.toEnvironmentUrlsOrDefault
import com.bitwarden.network.model.KdfTypeJson
import com.bitwarden.network.model.MasterPasswordUnlockDataJson
import com.bitwarden.network.model.OrganizationType
import com.bitwarden.network.model.PolicyTypeJson
import com.bitwarden.network.model.SyncResponseJson
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
@@ -33,7 +35,10 @@ fun UserStateJson.toRemovedPasswordUserStateJson(
val profile = account.profile
val updatedUserDecryptionOptions = profile
.userDecryptionOptions
?.copy(hasMasterPassword = false)
?.copy(
hasMasterPassword = false,
masterPasswordUnlock = null,
)
?: UserDecryptionOptionsJson(
hasMasterPassword = false,
trustedDeviceUserDecryptionOptions = null,
@@ -67,7 +72,10 @@ fun UserStateJson.toUpdatedUserStateJson(
?.let { syncUserDecryption ->
profile
.userDecryptionOptions
?.copy(masterPasswordUnlock = syncUserDecryption.masterPasswordUnlock)
?.copy(
hasMasterPassword = syncUserDecryption.masterPasswordUnlock != null,
masterPasswordUnlock = syncUserDecryption.masterPasswordUnlock,
)
?: UserDecryptionOptionsJson(
hasMasterPassword = syncUserDecryption.masterPasswordUnlock != null,
trustedDeviceUserDecryptionOptions = null,
@@ -77,35 +85,52 @@ fun UserStateJson.toUpdatedUserStateJson(
}
?: profile
.userDecryptionOptions
?.copy(masterPasswordUnlock = null)
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,
)
?.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)
},
)
return this.copy(
accounts = accounts
.toMutableMap()
.apply { replace(userId, updatedAccount) },
)
}
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
}
/**
@@ -113,20 +138,16 @@ fun UserStateJson.toUpdatedUserStateJson(
* their password.
*/
fun UserStateJson.toUserStateJsonWithPassword(
masterPasswordUnlock: MasterPasswordUnlockData?,
masterPasswordUnlock: MasterPasswordUnlockData,
): UserStateJson {
val account = this.activeAccount
val profile = account.profile
val userDecryptionOptions = profile.userDecryptionOptions
val masterPasswordUnlockJson = masterPasswordUnlock
?.let {
MasterPasswordUnlockDataJson(
salt = it.salt,
kdf = it.kdf.toKdfRequestModel(),
masterKeyWrappedUserKey = it.masterKeyWrappedUserKey,
)
}
?: userDecryptionOptions?.masterPasswordUnlock
val masterPasswordUnlockJson = MasterPasswordUnlockDataJson(
salt = masterPasswordUnlock.salt,
kdf = masterPasswordUnlock.kdf.toKdfRequestModel(),
masterKeyWrappedUserKey = masterPasswordUnlock.masterKeyWrappedUserKey,
)
val updatedProfile = profile
.copy(
forcePasswordResetReason = null,
@@ -192,7 +213,7 @@ fun UserStateJson.toUserState(
isBiometricsEnabledProvider: (userId: String) -> Boolean,
vaultUnlockTypeProvider: (userId: String) -> VaultUnlockType,
isDeviceTrustedProvider: (userId: String) -> Boolean,
getUserPolicies: (userId: String, policy: PolicyTypeJson) -> List<SyncResponseJson.Policy>,
getUserPolicies: (userId: String, policy: PolicyType) -> List<PolicyView>,
): UserState =
UserState(
activeUserId = this.activeUserId,
@@ -235,15 +256,15 @@ fun UserStateJson.toUserState(
val hasPersonalOwnershipRestrictedOrg = getUserPolicies(
userId,
PolicyTypeJson.PERSONAL_OWNERSHIP,
PolicyType.ORGANIZATION_DATA_OWNERSHIP,
)
.any { it.isEnabled }
.any { it.enabled }
val hasPersonalVaultExportRestrictedOrg = getUserPolicies(
userId,
PolicyTypeJson.DISABLE_PERSONAL_VAULT_EXPORT,
PolicyType.DISABLE_PERSONAL_VAULT_EXPORT,
)
.any { it.isEnabled }
.any { it.enabled }
UserState.Account(
userId = userId,

View File

@@ -6,6 +6,7 @@ package com.x8bit.bitwarden.data.autofill.accessibility.model
data class Browser(
val packageName: String,
val possibleUrlFieldIds: List<String>,
val possibleUrlSemanticIds: List<String> = emptyList(),
val urlExtractor: (String) -> String? = { it },
) {
constructor(
@@ -15,6 +16,7 @@ data class Browser(
) : this(
packageName = packageName,
possibleUrlFieldIds = listOf(urlFieldId),
possibleUrlSemanticIds = emptyList(),
urlExtractor = urlExtractor,
)
}

View File

@@ -43,22 +43,34 @@ class AccessibilityParserImpl(
return browser
.possibleUrlFieldIds
.flatMap { viewId ->
rootNode
.findAccessibilityNodeInfosByViewId("$packageName:id/$viewId")
.map { accessibilityNodeInfo ->
browser
.urlExtractor(accessibilityNodeInfo.text.toString())
?.trim()
?.let { rawUrl ->
if (rawUrl.contains(other = ".") && !rawUrl.hasHttpProtocol()) {
"https://$rawUrl"
} else {
rawUrl
}
}
rootNode.findAccessibilityNodeInfosByViewId("$packageName:id/$viewId")
}
.ifEmpty {
browser
.possibleUrlSemanticIds
.flatMap { semanticId ->
// Semantic IDs are exposed as viewIdResourceName via testTagsAsResourceId
// and cannot be found via findAccessibilityNodeInfosByViewId on Firefox.
accessibilityNodeInfoManager.findAccessibilityNodeInfoList(rootNode) {
it.viewIdResourceName == semanticId
}
}
}
.firstNotNullOfOrNull { node ->
val urlText = node.text?.toString()?.takeIf { it.isNotEmpty() }
?: node.contentDescription?.toString()?.takeIf { it.isNotEmpty() }
?: return@firstNotNullOfOrNull null
browser
.urlExtractor(urlText)
?.trim()
?.let { rawUrl ->
if (rawUrl.contains(other = ".") && !rawUrl.hasHttpProtocol()) {
"https://$rawUrl"
} else {
rawUrl
}
}
}
.firstOrNull()
?.toUriOrNull()
}
}

View File

@@ -2,6 +2,15 @@ package com.x8bit.bitwarden.data.autofill.accessibility.util
import com.x8bit.bitwarden.data.autofill.accessibility.model.Browser
/**
* URL extractor for Mozilla browsers whose toolbar exposes the URL via [contentDescription]
* rather than [text]. The content description format is " $url. Search or enter address".
* Falls back to [text] for builds where the URL is still exposed via [text].
*/
private val mozillaUrlExtractor: (String) -> String? = { text ->
text.trim().split(" ").firstOrNull()?.trimEnd('.')?.takeIf { it.isNotEmpty() }
}
/**
* Determines if the [String] receiver is a package name for a supported browser and returns that
* [Browser] if it is a match.
@@ -36,14 +45,21 @@ private val ACCESSIBILITY_SUPPORTED_BROWSERS = listOf(
Browser(packageName = "com.cookiegames.smartcookie", urlFieldId = "search"),
Browser(
packageName = "com.cookiejarapps.android.smartcookieweb",
urlFieldId = "mozac_browser_toolbar_url_view",
possibleUrlFieldIds = listOf("mozac_browser_toolbar_url_view"),
possibleUrlSemanticIds = listOf("ADDRESSBAR_URL_BOX"),
urlExtractor = mozillaUrlExtractor,
),
Browser(packageName = "com.duckduckgo.mobile.android", urlFieldId = "omnibarTextInput"),
Browser(packageName = "com.ecosia.android", urlFieldId = "url_bar"),
Browser(packageName = "com.google.android.apps.chrome", urlFieldId = "url_bar"),
Browser(packageName = "com.google.android.apps.chrome_dev", urlFieldId = "url_bar"),
// "com.google.android.captiveportallogin": URL displayed in ActionBar subtitle without viewId
Browser(packageName = "com.iode.firefox", urlFieldId = "mozac_browser_toolbar_url_view"),
Browser(
packageName = "com.iode.firefox",
possibleUrlFieldIds = listOf("mozac_browser_toolbar_url_view"),
possibleUrlSemanticIds = listOf("ADDRESSBAR_URL_BOX"),
urlExtractor = mozillaUrlExtractor,
),
Browser(packageName = "com.jamal2367.styx", urlFieldId = "search"),
Browser(packageName = "com.kiwibrowser.browser", urlFieldId = "url_bar"),
Browser(packageName = "com.kiwibrowser.browser.dev", urlFieldId = "url_bar"),
@@ -67,7 +83,12 @@ private val ACCESSIBILITY_SUPPORTED_BROWSERS = listOf(
Browser(
packageName = "com.qwant.liberty",
// 2nd = Legacy (before v4)
possibleUrlFieldIds = listOf("mozac_browser_toolbar_url_view", "url_bar_title"),
possibleUrlFieldIds = listOf(
"mozac_browser_toolbar_url_view",
"url_bar_title",
),
possibleUrlSemanticIds = listOf("ADDRESSBAR_URL_BOX"),
urlExtractor = mozillaUrlExtractor,
),
Browser(packageName = "com.rainsee.create", urlFieldId = "search_box"),
Browser(packageName = "com.sec.android.app.sbrowser", urlFieldId = "location_bar_edit_text"),
@@ -102,7 +123,9 @@ private val ACCESSIBILITY_SUPPORTED_BROWSERS = listOf(
Browser(packageName = "idm.internet.download.manager.plus", urlFieldId = "search"),
Browser(
packageName = "io.github.forkmaintainers.iceraven",
urlFieldId = "mozac_browser_toolbar_url_view",
possibleUrlFieldIds = listOf("mozac_browser_toolbar_url_view"),
possibleUrlSemanticIds = listOf("ADDRESSBAR_URL_BOX"),
urlExtractor = mozillaUrlExtractor,
),
Browser(packageName = "mark.via", urlFieldId = "am,an"),
Browser(packageName = "mark.via.gp", urlFieldId = "as"),
@@ -129,78 +152,155 @@ private val ACCESSIBILITY_SUPPORTED_BROWSERS = listOf(
Browser(
packageName = "org.gnu.icecat",
// 2nd = Anticipation
possibleUrlFieldIds = listOf("url_bar_title", "mozac_browser_toolbar_url_view"),
possibleUrlFieldIds = listOf(
"url_bar_title",
"mozac_browser_toolbar_url_view",
),
possibleUrlSemanticIds = listOf("ADDRESSBAR_URL_BOX"),
urlExtractor = mozillaUrlExtractor,
),
Browser(
packageName = "org.ironfoxoss.ironfox",
// 2nd = Legacy
possibleUrlFieldIds = listOf("mozac_browser_toolbar_url_view", "url_bar_title"),
possibleUrlFieldIds = listOf(
"mozac_browser_toolbar_url_view",
"url_bar_title",
),
possibleUrlSemanticIds = listOf("ADDRESSBAR_URL_BOX"),
urlExtractor = mozillaUrlExtractor,
),
Browser(
packageName = "org.ironfoxoss.ironfox.nightly",
// 2nd = Legacy
possibleUrlFieldIds = listOf("mozac_browser_toolbar_url_view", "url_bar_title"),
possibleUrlFieldIds = listOf(
"mozac_browser_toolbar_url_view",
"url_bar_title",
),
possibleUrlSemanticIds = listOf("ADDRESSBAR_URL_BOX"),
urlExtractor = mozillaUrlExtractor,
),
Browser(
packageName = "org.mozilla.fenix",
possibleUrlFieldIds = listOf("mozac_browser_toolbar_url_view"),
possibleUrlSemanticIds = listOf("ADDRESSBAR_URL_BOX"),
urlExtractor = mozillaUrlExtractor,
),
Browser(packageName = "org.mozilla.fenix", urlFieldId = "mozac_browser_toolbar_url_view"),
// [DEPRECATED ENTRY]
Browser(
packageName = "org.mozilla.fenix.nightly",
urlFieldId = "mozac_browser_toolbar_url_view",
possibleUrlFieldIds = listOf(
"mozac_browser_toolbar_url_view",
),
possibleUrlSemanticIds = listOf("ADDRESSBAR_URL_BOX"),
urlExtractor = mozillaUrlExtractor,
),
// [DEPRECATED ENTRY]
Browser(
packageName = "org.mozilla.fennec_aurora",
possibleUrlFieldIds = listOf("mozac_browser_toolbar_url_view", "url_bar_title"),
// 2nd = Legacy
possibleUrlFieldIds = listOf(
"mozac_browser_toolbar_url_view",
"url_bar_title",
),
possibleUrlSemanticIds = listOf("ADDRESSBAR_URL_BOX"),
urlExtractor = mozillaUrlExtractor,
),
Browser(
packageName = "org.mozilla.fennec_fdroid",
// 2nd = Legacy
possibleUrlFieldIds = listOf("mozac_browser_toolbar_url_view", "url_bar_title"),
possibleUrlFieldIds = listOf(
"mozac_browser_toolbar_url_view",
"url_bar_title",
),
possibleUrlSemanticIds = listOf("ADDRESSBAR_URL_BOX"),
urlExtractor = mozillaUrlExtractor,
),
Browser(
packageName = "org.mozilla.firefox",
// 2nd = Legacy
possibleUrlFieldIds = listOf("mozac_browser_toolbar_url_view", "url_bar_title"),
possibleUrlFieldIds = listOf(
"mozac_browser_toolbar_url_view",
"url_bar_title",
),
possibleUrlSemanticIds = listOf("ADDRESSBAR_URL_BOX"),
urlExtractor = mozillaUrlExtractor,
),
Browser(
packageName = "org.mozilla.firefox_beta",
// 2nd = Legacy
possibleUrlFieldIds = listOf("mozac_browser_toolbar_url_view", "url_bar_title"),
possibleUrlFieldIds = listOf(
"mozac_browser_toolbar_url_view",
"url_bar_title",
),
possibleUrlSemanticIds = listOf("ADDRESSBAR_URL_BOX"),
urlExtractor = mozillaUrlExtractor,
),
Browser(
packageName = "org.mozilla.focus",
// 2nd = Legacy
possibleUrlFieldIds = listOf("mozac_browser_toolbar_url_view", "display_url"),
possibleUrlFieldIds = listOf(
"mozac_browser_toolbar_url_view",
"display_url",
),
possibleUrlSemanticIds = listOf("ADDRESSBAR_URL_BOX"),
urlExtractor = mozillaUrlExtractor,
),
Browser(
packageName = "org.mozilla.focus.beta",
// 2nd = Legacy
possibleUrlFieldIds = listOf("mozac_browser_toolbar_url_view", "display_url"),
possibleUrlFieldIds = listOf(
"mozac_browser_toolbar_url_view",
"display_url",
),
possibleUrlSemanticIds = listOf("ADDRESSBAR_URL_BOX"),
urlExtractor = mozillaUrlExtractor,
),
Browser(
packageName = "org.mozilla.focus.nightly",
// 2nd = Legacy
possibleUrlFieldIds = listOf("mozac_browser_toolbar_url_view", "display_url"),
possibleUrlFieldIds = listOf(
"mozac_browser_toolbar_url_view",
"display_url",
),
possibleUrlSemanticIds = listOf("ADDRESSBAR_URL_BOX"),
urlExtractor = mozillaUrlExtractor,
),
Browser(
packageName = "org.mozilla.klar",
// 2nd = Legacy
possibleUrlFieldIds = listOf("mozac_browser_toolbar_url_view", "display_url"),
possibleUrlFieldIds = listOf(
"mozac_browser_toolbar_url_view",
"display_url",
),
possibleUrlSemanticIds = listOf("ADDRESSBAR_URL_BOX"),
urlExtractor = mozillaUrlExtractor,
),
Browser(
packageName = "org.mozilla.reference.browser",
urlFieldId = "mozac_browser_toolbar_url_view",
possibleUrlFieldIds = listOf("mozac_browser_toolbar_url_view"),
possibleUrlSemanticIds = listOf("ADDRESSBAR_URL_BOX"),
urlExtractor = mozillaUrlExtractor,
),
Browser(packageName = "org.mozilla.rocket", urlFieldId = "display_url"),
Browser(
packageName = "org.torproject.torbrowser",
// 2nd = Legacy (before v10.0.3)
possibleUrlFieldIds = listOf("mozac_browser_toolbar_url_view", "url_bar_title"),
possibleUrlFieldIds = listOf(
"mozac_browser_toolbar_url_view",
"url_bar_title",
),
possibleUrlSemanticIds = listOf("ADDRESSBAR_URL_BOX"),
urlExtractor = mozillaUrlExtractor,
),
Browser(
packageName = "org.torproject.torbrowser_alpha",
// 2nd = Legacy (before v10.0a8)
possibleUrlFieldIds = listOf("mozac_browser_toolbar_url_view", "url_bar_title"),
possibleUrlFieldIds = listOf(
"mozac_browser_toolbar_url_view",
"url_bar_title",
),
possibleUrlSemanticIds = listOf("ADDRESSBAR_URL_BOX"),
urlExtractor = mozillaUrlExtractor,
),
Browser(packageName = "org.ungoogled.chromium.extensions.stable", urlFieldId = "url_bar"),
Browser(packageName = "org.ungoogled.chromium.stable", urlFieldId = "url_bar"),

View File

@@ -63,13 +63,16 @@ class BrowserThirdPartyAutofillManagerImpl(
var thirdPartyEnabled = false
val isThirdPartyAvailable = cursor
?.use {
it.moveToFirst()
thirdPartyEnabled = it
.getColumnIndex(THIRD_PARTY_MODE_COLUMN)
.takeUnless { columnIndex -> columnIndex == -1 }
?.let { columnIndex -> it.getInt(columnIndex) != 0 }
?: false
true
if (it.moveToFirst()) {
thirdPartyEnabled = it
.getColumnIndex(THIRD_PARTY_MODE_COLUMN)
.takeUnless { columnIndex -> columnIndex == -1 }
?.let { columnIndex -> it.getInt(columnIndex) != 0 }
?: false
true
} else {
false
}
}
?: false
return BrowserThirdPartyAutoFillData(

View File

@@ -6,7 +6,7 @@ import android.service.autofill.FillRequest
import android.service.autofill.SaveCallback
import android.service.autofill.SaveRequest
import com.bitwarden.core.data.manager.dispatcher.DispatcherManager
import com.bitwarden.network.model.PolicyTypeJson
import com.bitwarden.policies.PolicyType
import com.x8bit.bitwarden.data.autofill.builder.FillResponseBuilder
import com.x8bit.bitwarden.data.autofill.builder.FilledDataBuilder
import com.x8bit.bitwarden.data.autofill.builder.SaveInfoBuilder
@@ -80,7 +80,7 @@ class AutofillProcessorImpl(
return
}
if (policyManager.getActivePolicies(PolicyTypeJson.PERSONAL_OWNERSHIP).any()) {
if (policyManager.getActivePolicies(PolicyType.ORGANIZATION_DATA_OWNERSHIP).any()) {
saveCallback.onSuccess()
return
}

View File

@@ -1,6 +1,6 @@
package com.x8bit.bitwarden.data.autofill.provider
import com.bitwarden.network.model.PolicyTypeJson
import com.bitwarden.policies.PolicyType
import com.bitwarden.vault.CipherListView
import com.bitwarden.vault.CipherListViewType
import com.bitwarden.vault.CipherRepromptType
@@ -58,7 +58,7 @@ class AutofillCipherProviderImpl(
override suspend fun getCardAutofillCiphers(): List<AutofillCipher.Card> {
val cipherListViews = getUnlockedCipherListViewsOrNull() ?: return emptyList()
val organizationIdsWithCardTypeRestrictions = policyManager
.getActivePolicies(PolicyTypeJson.RESTRICT_ITEM_TYPES)
.getActivePolicies(PolicyType.RESTRICTED_ITEM_TYPES)
.map { it.organizationId }
return cipherListViews
.mapNotNull { cipherListView ->

View File

@@ -1,6 +1,8 @@
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
/**
@@ -15,10 +17,9 @@ const val UPGRADED_TO_PREMIUM_LEARN_MORE_URL: String =
interface PremiumStateManager {
/**
* Emits `true` when the current user is eligible to see the Premium upgrade banner,
* or `false` otherwise.
* Emits a [PremiumCard] for the current user indicating what Premium card should be displayed.
*/
val isPremiumUpgradeBannerEligibleFlow: StateFlow<Boolean>
val premiumCardStateFlow: StateFlow<PremiumCard>
/**
* Emits `true` while the active user is eligible to see the "Upgraded to Premium" action
@@ -38,6 +39,11 @@ interface PremiumStateManager {
*/
val subscriptionStatusStateFlow: StateFlow<SubscriptionStatusState>
/**
* Emits the active user's current [UpgradeLifecycleState].
*/
val upgradeLifecycleStateFlow: StateFlow<UpgradeLifecycleState>
/**
* Emits whether the current state should be treated as self-hosted for premium upgrade
* gating. Reactive equivalent of [isSelfHosted].
@@ -66,4 +72,10 @@ interface PremiumStateManager {
* never re-appears for that user.
*/
fun dismissUpgradedToPremiumCard()
/**
* Marks the active user as having a Premium upgrade in flight (Stripe checkout completed
* but the server has not yet flipped `isPremium`).
*/
fun markPremiumUpgradePending(userId: String)
}

View File

@@ -5,12 +5,15 @@ 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
import com.x8bit.bitwarden.data.billing.repository.model.SubscriptionStatusState
import com.x8bit.bitwarden.data.billing.repository.model.UpgradeLifecycleState
import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource
import com.x8bit.bitwarden.data.platform.manager.FeatureFlagManager
import com.x8bit.bitwarden.data.platform.manager.PushManager
@@ -49,7 +52,7 @@ class PremiumStateManagerImpl(
private val settingsDiskSource: SettingsDiskSource,
vaultRepository: VaultRepository,
private val featureFlagManager: FeatureFlagManager,
private val environmentRepository: EnvironmentRepository,
environmentRepository: EnvironmentRepository,
pushManager: PushManager,
private val clock: Clock,
dispatcherManager: DispatcherManager,
@@ -86,61 +89,105 @@ class PremiumStateManagerImpl(
)
@OptIn(ExperimentalCoroutinesApi::class)
override val isPremiumUpgradeBannerEligibleFlow: StateFlow<Boolean> =
override val upgradeLifecycleStateFlow: StateFlow<UpgradeLifecycleState> =
combine(
authDiskSource.userStateFlow,
billingRepository.isInAppBillingSupportedFlow,
featureFlagManager.getFeatureFlagFlow(FlagKey.MobilePremiumUpgrade),
subscriptionStatusStateFlow,
authDiskSource.activeUserIdChangesFlow
.flatMapLatest { userId ->
userId
?.let { id ->
settingsDiskSource
.getPremiumUpgradeBannerDismissedFlow(id)
.getPremiumUpgradePendingFlow(id)
.map { it ?: false }
}
?: flowOf(false)
},
) { userState, subscriptionStatus, isPending ->
deriveLifecycleState(
userState = userState,
subscriptionStatus = subscriptionStatus,
isPending = isPending,
)
}
.distinctUntilChanged()
.stateIn(
scope = unconfinedScope,
started = SharingStarted.Eagerly,
initialValue = deriveLifecycleState(
userState = authDiskSource.userState,
subscriptionStatus = subscriptionStatusStateFlow.value,
isPending = authDiskSource.userState
?.activeUserId
?.let { settingsDiskSource.getPremiumUpgradePending(userId = it) }
?: false,
),
)
@OptIn(ExperimentalCoroutinesApi::class)
override val premiumCardStateFlow: StateFlow<PremiumCard> =
combine(
authDiskSource.userStateFlow.map { it?.activeAccount },
billingRepository.isInAppBillingSupportedFlow,
featureFlagManager.getFeatureFlagFlow(FlagKey.MobilePremiumUpgrade),
authDiskSource.activeUserIdChangesFlow.flatMapLatest { userId ->
userId
?.let { id ->
settingsDiskSource
.getPremiumUpgradeBannerDismissedFlow(id)
.map { it ?: false }
}
?: flowOf(false)
},
vaultRepository.vaultDataStateFlow,
) {
userState,
account,
isInAppBillingSupported,
featureFlagEnabled,
isDismissed,
isUpgradeCardDismissed,
vaultDataState,
->
BannerInputs(
userState = userState,
account = account,
isInAppBillingSupported = isInAppBillingSupported,
featureFlagEnabled = featureFlagEnabled,
isDismissed = isDismissed,
isUpgradeCardDismissed = isUpgradeCardDismissed,
vaultDataState = vaultDataState,
)
}
.combine(subscriptionStatusStateFlow) { inputs, subscriptionStatus ->
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 hasPremium = profile.hasPremiumPersonally == true ||
profile.hasPremiumFromOrganization == true
val isEffectivelyPremium = hasPremium &&
!subscriptionStatus.isInTroubleState()
.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 itemCount = inputs.vaultDataState.activeVaultItemCount()
val showCard = inputs.isInAppBillingSupported &&
isAccountOldEnough &&
itemCount >= PREMIUM_UPGRADE_MINIMUM_VAULT_ITEMS &&
!inputs.isUpgradeCardDismissed
initialCard.takeIf { showCard } ?: PremiumCard.NONE
}
!isEffectivelyPremium &&
inputs.isInAppBillingSupported &&
inputs.featureFlagEnabled &&
!inputs.isDismissed &&
isAccountOldEnough &&
itemCount >= PREMIUM_UPGRADE_MINIMUM_VAULT_ITEMS
PremiumCard.NEEDS_ATTENTION,
PremiumCard.NONE,
-> initialCard
}
}
.stateIn(
scope = unconfinedScope,
started = SharingStarted.Eagerly,
initialValue = false,
initialValue = PremiumCard.NONE,
)
override val isSelfHostedFlow: StateFlow<Boolean> =
@@ -254,6 +301,7 @@ class PremiumStateManagerImpl(
// an upgrade.
if (previous?.first == currentUserId && !previous.second) {
markUpgradedToPremiumCardPending(userId = currentUserId)
clearPremiumUpgradePending(userId = currentUserId)
}
}
.launchIn(unconfinedScope)
@@ -285,6 +333,35 @@ class PremiumStateManagerImpl(
)
}
override fun markPremiumUpgradePending(userId: String) {
settingsDiskSource.storePremiumUpgradePending(
userId = userId,
isPending = true,
)
}
private fun clearPremiumUpgradePending(userId: String) {
settingsDiskSource.storePremiumUpgradePending(
userId = userId,
isPending = null,
)
}
private fun deriveLifecycleState(
userState: UserStateJson?,
subscriptionStatus: SubscriptionStatusState,
isPending: Boolean,
): UpgradeLifecycleState {
val profile = userState?.activeAccount?.profile ?: return UpgradeLifecycleState.Free
val hasPremium = profile.hasPremiumPersonally == true ||
profile.hasPremiumFromOrganization == true
return when {
hasPremium -> UpgradeLifecycleState.Premium(subscriptionStatus = subscriptionStatus)
isPending -> UpgradeLifecycleState.UpgradePending
else -> UpgradeLifecycleState.Free
}
}
private fun markUpgradedToPremiumCardPending(userId: String) {
// Don't re-arm the card if the user has already consumed it for this account.
if (settingsDiskSource.getUpgradedToPremiumCardConsumed(userId = userId) == true) {
@@ -321,31 +398,41 @@ class PremiumStateManagerImpl(
}
private data class BannerInputs(
val userState: UserStateJson?,
val account: AccountJson?,
val isInAppBillingSupported: Boolean,
val featureFlagEnabled: Boolean,
val isDismissed: Boolean,
val isUpgradeCardDismissed: Boolean,
val vaultDataState: DataState<VaultData>,
)
/**
* Returns `true` when the given [SubscriptionStatusState] represents a subscription substate
* that should disqualify a user from being treated as effectively premium.
* Returns a [PremiumCard] for the given [SubscriptionStatusState] and subscription substate.
*/
private fun SubscriptionStatusState.isInTroubleState(): Boolean =
this is SubscriptionStatusState.Available &&
when (this.status) {
PremiumSubscriptionStatus.CANCELED,
PremiumSubscriptionStatus.PAST_DUE,
PremiumSubscriptionStatus.PAUSED,
PremiumSubscriptionStatus.UPDATE_PAYMENT,
-> true
private fun SubscriptionStatusState.premiumCardState(): PremiumCard =
when (this) {
is SubscriptionStatusState.Available -> {
when (this.status) {
PremiumSubscriptionStatus.PAST_DUE,
PremiumSubscriptionStatus.UPDATE_PAYMENT,
-> PremiumCard.NEEDS_ATTENTION
PremiumSubscriptionStatus.ACTIVE,
PremiumSubscriptionStatus.PENDING_CANCELLATION,
-> false
PremiumSubscriptionStatus.EXPIRED,
PremiumSubscriptionStatus.PAUSED,
-> PremiumCard.UPGRADE
PremiumSubscriptionStatus.ACTIVE,
PremiumSubscriptionStatus.CANCELED,
PremiumSubscriptionStatus.PENDING_CANCELLATION,
-> PremiumCard.NONE
}
}
is SubscriptionStatusState.Error,
SubscriptionStatusState.Loading,
SubscriptionStatusState.NoSubscription,
-> PremiumCard.NONE
}
/**
* Returns `true` if this [Instant] is older than the given number of [days] based on
* the provided [clock]. Returns `false` if the receiver is `null`.

View File

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

View File

@@ -7,6 +7,13 @@ enum class PremiumSubscriptionStatus {
ACTIVE,
CANCELED,
/**
* The subscription's initial payment never succeeded and Stripe voided the invoice, so
* the subscription never became active. Distinct from [CANCELED], which describes a
* subscription that was previously active.
*/
EXPIRED,
/**
* The subscription is scheduled to cancel at a future date but is still active until then.
*/

View File

@@ -0,0 +1,33 @@
package com.x8bit.bitwarden.data.billing.repository.model
/**
* Represents the active user's position in the Premium upgrade lifecycle.
*
* Transitions:
* - [Free] → [UpgradePending] when the user completes Stripe checkout and the post-checkout
* sync still reports the user as non-premium — checkout is done, backend reconciliation
* is in flight.
* - [UpgradePending] → [Premium] when the server flips `isPremium` to `true`.
*
* Cancellation, expiration, and other terminal substates are surfaced via
* [Premium.subscriptionStatus] rather than as separate leaves.
*/
sealed class UpgradeLifecycleState {
/**
* The user has no Premium subscription and no upgrade is in flight.
*/
data object Free : UpgradeLifecycleState()
/**
* Stripe checkout completed but the server has not yet flipped `isPremium`.
*/
data object UpgradePending : UpgradeLifecycleState()
/**
* The user holds Premium; [subscriptionStatus] carries the substate (active, canceled, etc).
*/
data class Premium(
val subscriptionStatus: SubscriptionStatusState,
) : UpgradeLifecycleState()
}

View File

@@ -3,6 +3,7 @@ package com.x8bit.bitwarden.data.billing.repository.util
import com.bitwarden.network.model.BitwardenDiscountJson
import com.bitwarden.network.model.BitwardenSubscriptionResponseJson
import com.bitwarden.network.model.CadenceTypeJson
import com.bitwarden.network.model.CartItemJson
import com.bitwarden.network.model.DiscountTypeJson
import com.bitwarden.network.model.SubscriptionStatusJson
import com.x8bit.bitwarden.data.billing.repository.model.PlanCadence
@@ -11,28 +12,35 @@ import com.x8bit.bitwarden.data.billing.repository.model.SubscriptionInfo
import java.math.BigDecimal
import java.math.RoundingMode
private val PERCENT_DIVISOR: BigDecimal = BigDecimal("100")
private const val MONEY_SCALE: Int = 2
/**
* Maps a [BitwardenSubscriptionResponseJson] into a [SubscriptionInfo] domain
* model.
*
* `discountAmount` is resolved at mapping time: fixed-amount discounts pass
* through as-is; percent-off discounts apply to the password manager subtotal
* (`seatsCost + storageCost`). `nextChargeTotal` is computed client-side as
* `seatsCost + storageCost - discountAmount + estimatedTax` because the server
* Each line item's `cost` is a per-unit price, so its contribution is
* `cost * quantity`. Two discount channels are combined into `discountAmount`:
* the cart-level discount applies to the password manager subtotal
* (`seatsCost + storageCost`), and the Password Manager seats item-level
* discount applies to the seats line total. Item-level discounts on other line
* items are intentionally ignored, mirroring the web client. Fixed-amount
* discounts pass through as-is; percent-off discounts treat a value below 1 as
* an already-decimal fraction and round half-up. `nextChargeTotal` is computed
* client-side as `subtotal - discountAmount + estimatedTax` because the server
* does not expose a precomputed total.
*/
fun BitwardenSubscriptionResponseJson.toSubscriptionInfo(): SubscriptionInfo {
val seatsCost = cart.passwordManager.seats.cost
val storageCost = cart.passwordManager.additionalStorage?.cost
val discountAmount = cart.discount?.toMoneyAmount(
subtotal = seatsCost + (storageCost ?: BigDecimal.ZERO),
)
val seatsCost = cart.passwordManager.seats.lineTotal()
val storageCost = cart.passwordManager.additionalStorage?.lineTotal()
val subtotal = seatsCost + (storageCost ?: BigDecimal.ZERO)
val cartDiscount = cart.discount?.toDiscountAmount(baseAmount = subtotal)
val seatsDiscount = cart.passwordManager.seats.discount
?.toDiscountAmount(baseAmount = seatsCost)
val discountAmount = listOfNotNull(cartDiscount, seatsDiscount)
.takeIf { it.isNotEmpty() }
?.reduce(BigDecimal::add)
val estimatedTax = cart.estimatedTax
val nextChargeTotal = seatsCost +
(storageCost ?: BigDecimal.ZERO) -
val nextChargeTotal = subtotal -
(discountAmount ?: BigDecimal.ZERO) +
estimatedTax
@@ -64,10 +72,9 @@ private fun BitwardenSubscriptionResponseJson.toPremiumSubscriptionStatus():
}
}
SubscriptionStatusJson.CANCELED,
SubscriptionStatusJson.INCOMPLETE_EXPIRED,
-> PremiumSubscriptionStatus.CANCELED
SubscriptionStatusJson.CANCELED -> PremiumSubscriptionStatus.CANCELED
SubscriptionStatusJson.INCOMPLETE_EXPIRED -> PremiumSubscriptionStatus.EXPIRED
SubscriptionStatusJson.INCOMPLETE,
SubscriptionStatusJson.UNPAID,
-> PremiumSubscriptionStatus.UPDATE_PAYMENT
@@ -77,16 +84,18 @@ private fun BitwardenSubscriptionResponseJson.toPremiumSubscriptionStatus():
SubscriptionStatusJson.PAUSED -> PremiumSubscriptionStatus.PAUSED
}
private fun CartItemJson.lineTotal(): BigDecimal = cost.multiply(quantity.toBigDecimal())
private fun CadenceTypeJson.toPlanCadence(): PlanCadence = when (this) {
CadenceTypeJson.ANNUALLY -> PlanCadence.ANNUALLY
CadenceTypeJson.MONTHLY -> PlanCadence.MONTHLY
}
private fun BitwardenDiscountJson.toMoneyAmount(subtotal: BigDecimal): BigDecimal =
private fun BitwardenDiscountJson.toDiscountAmount(baseAmount: BigDecimal): BigDecimal =
when (type) {
DiscountTypeJson.AMOUNT_OFF -> value
DiscountTypeJson.PERCENT_OFF ->
subtotal
.multiply(value)
.divide(PERCENT_DIVISOR, MONEY_SCALE, RoundingMode.HALF_EVEN)
DiscountTypeJson.PERCENT_OFF -> {
val percentage = if (value < BigDecimal.ONE) value else value.movePointLeft(2)
baseAmount.multiply(percentage).setScale(MONEY_SCALE, RoundingMode.HALF_UP)
}
}

View File

@@ -27,4 +27,10 @@ interface EnvironmentDiskSource {
* Stores the [urls] for the given [userEmail].
*/
fun storePreAuthEnvironmentUrlDataForEmail(userEmail: String, urls: EnvironmentUrlDataJson)
/**
* The fill-assist URL provided by the server config, or `null` if the server does not
* configure fill-assist targeting rules.
*/
var fillAssistRulesUrl: String?
}

View File

@@ -11,6 +11,7 @@ import kotlinx.serialization.json.Json
private const val PRE_AUTH_URLS_KEY = "preAuthEnvironmentUrls"
private const val EMAIL_VERIFICATION_URLS = "emailVerificationUrls"
private const val FILL_ASSIST_RULES_URL_KEY = "fillAssistRulesUrl"
/**
* Primary implementation of [EnvironmentDiskSource].
@@ -54,4 +55,8 @@ class EnvironmentDiskSourceImpl(
value = json.encodeToString(urls),
)
}
override var fillAssistRulesUrl: String?
get() = getString(key = FILL_ASSIST_RULES_URL_KEY)
set(value) = putString(key = FILL_ASSIST_RULES_URL_KEY, value = value)
}

View File

@@ -40,6 +40,17 @@ interface SettingsDiskSource : FlightRecorderDiskSource {
*/
var initialAutofillDialogShown: Boolean?
/**
* Indicates if the accessibility disclaimer has been displayed to the user.
*/
var hasShownAccessibilityDisclaimer: Boolean?
/**
* Emits up-to-date values indicating if the accessibility disclaimer has been displayed to
* the user.
*/
val hasShownAccessibilityDisclaimerFlow: Flow<Boolean?>
/**
* The currently persisted app theme (or `null` if not set).
*/
@@ -182,6 +193,25 @@ interface SettingsDiskSource : FlightRecorderDiskSource {
*/
fun getUpgradedToPremiumCardPendingFlow(userId: String): Flow<Boolean?>
/**
* Retrieves the stored value of whether a Premium upgrade is awaiting server confirmation
* for the given [userId].
*/
fun getPremiumUpgradePending(userId: String): Boolean?
/**
* Stores whether a Premium upgrade is awaiting server confirmation for the given [userId].
*/
fun storePremiumUpgradePending(
userId: String,
isPending: Boolean?,
)
/**
* Emits updates that track [getPremiumUpgradePending] for the given [userId].
*/
fun getPremiumUpgradePendingFlow(userId: String): Flow<Boolean?>
/**
* Retrieves the biometric integrity validity for the given [userId] and
* [systemBioIntegrityState].

View File

@@ -35,6 +35,7 @@ private const val ACCOUNT_BIOMETRIC_INTEGRITY_VALID_KEY = "accountBiometricInteg
private const val CRASH_LOGGING_ENABLED_KEY = "crashLoggingEnabled"
private const val CLEAR_CLIPBOARD_INTERVAL_KEY = "clearClipboard"
private const val INITIAL_AUTOFILL_DIALOG_SHOWN = "addSitePromptShown"
private const val HAS_SHOWN_ACCESSIBILITY_DISCLAIMER_KEY = "hasShownAccessibilityDisclaimer"
private const val HAS_USER_LOGGED_IN_OR_CREATED_AN_ACCOUNT_KEY = "hasUserLoggedInOrCreatedAccount"
private const val SHOW_AUTOFILL_SETTING_BADGE = "showAutofillSettingBadge"
private const val SHOW_BROWSER_AUTOFILL_SETTING_BADGE = "showBrowserAutofillSettingBadge"
@@ -57,11 +58,13 @@ private const val UPGRADED_TO_PREMIUM_CARD_CONSUMED =
"upgradedToPremiumCardConsumed"
private const val UPGRADED_TO_PREMIUM_CARD_PENDING =
"upgradedToPremiumCardPending"
private const val PREMIUM_UPGRADE_PENDING =
"premiumUpgradePending"
/**
* Primary implementation of [SettingsDiskSource].
*/
@Suppress("TooManyFunctions")
@Suppress("TooManyFunctions", "LargeClass")
class SettingsDiskSourceImpl(
private val sharedPreferences: SharedPreferences,
private val json: Json,
@@ -107,6 +110,9 @@ class SettingsDiskSourceImpl(
private val mutableUpgradedToPremiumCardPendingFlowMap =
mutableMapOf<String, MutableSharedFlow<Boolean?>>()
private val mutablePremiumUpgradePendingFlowMap =
mutableMapOf<String, MutableSharedFlow<Boolean?>>()
private val mutableIsIconLoadingDisabledFlow = bufferedMutableSharedFlow<Boolean?>()
private val mutableIsCrashLoggingEnabledFlow = bufferedMutableSharedFlow<Boolean?>()
@@ -123,6 +129,8 @@ class SettingsDiskSourceImpl(
private val mutableIsDynamicColorsEnabledFlow = bufferedMutableSharedFlow<Boolean?>()
private val mutableHasShownAccessibilityDisclaimerFlow = bufferedMutableSharedFlow<Boolean?>()
init {
migrateScreenCaptureSetting()
}
@@ -162,6 +170,17 @@ class SettingsDiskSourceImpl(
)
}
override var hasShownAccessibilityDisclaimer: Boolean?
set(value) {
putBoolean(HAS_SHOWN_ACCESSIBILITY_DISCLAIMER_KEY, value)
mutableHasShownAccessibilityDisclaimerFlow.tryEmit(value)
}
get() = getBoolean(HAS_SHOWN_ACCESSIBILITY_DISCLAIMER_KEY)
override val hasShownAccessibilityDisclaimerFlow: Flow<Boolean?>
get() = mutableHasShownAccessibilityDisclaimerFlow
.onSubscription { emit(hasShownAccessibilityDisclaimer) }
override var systemBiometricIntegritySource: String?
get() = getString(key = SYSTEM_BIOMETRIC_INTEGRITY_SOURCE_KEY)
set(value) {
@@ -264,6 +283,8 @@ class SettingsDiskSourceImpl(
// - Premium upgrade banner dismissed
// - Upgraded to Premium action card consumed
// - Upgraded to Premium action card pending
// - Premium upgrade pending
// - Has shown accessibility disclaimer dialog
}
override fun getIntroducingArchiveActionCardDismissed(userId: String): Boolean? =
@@ -346,6 +367,26 @@ class SettingsDiskSourceImpl(
getMutableUpgradedToPremiumCardPendingFlow(userId = userId)
.onSubscription { emit(getUpgradedToPremiumCardPending(userId = userId)) }
override fun getPremiumUpgradePending(userId: String): Boolean? =
getBoolean(
key = PREMIUM_UPGRADE_PENDING.appendIdentifier(identifier = userId),
)
override fun storePremiumUpgradePending(
userId: String,
isPending: Boolean?,
) {
putBoolean(
key = PREMIUM_UPGRADE_PENDING.appendIdentifier(identifier = userId),
value = isPending,
)
getMutablePremiumUpgradePendingFlow(userId = userId).tryEmit(isPending)
}
override fun getPremiumUpgradePendingFlow(userId: String): Flow<Boolean?> =
getMutablePremiumUpgradePendingFlow(userId = userId)
.onSubscription { emit(getPremiumUpgradePending(userId = userId)) }
override fun getAccountBiometricIntegrityValidity(
userId: String,
systemBioIntegrityState: String,
@@ -711,6 +752,13 @@ class SettingsDiskSourceImpl(
bufferedMutableSharedFlow(replay = 1)
}
private fun getMutablePremiumUpgradePendingFlow(
userId: String,
): MutableSharedFlow<Boolean?> =
mutablePremiumUpgradePendingFlowMap.getOrPut(userId) {
bufferedMutableSharedFlow(replay = 1)
}
private fun getMutableLastSyncFlow(
userId: String,
): MutableSharedFlow<Instant?> =

View File

@@ -7,6 +7,7 @@ import com.bitwarden.network.interceptor.BaseUrlsProvider
import com.bitwarden.network.model.BitwardenServiceClientConfig
import com.bitwarden.network.service.ConfigService
import com.bitwarden.network.service.EventService
import com.bitwarden.network.service.FillAssistService
import com.bitwarden.network.service.PushService
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
import com.x8bit.bitwarden.data.auth.manager.AuthTokenManager
@@ -32,6 +33,12 @@ import javax.inject.Singleton
@InstallIn(SingletonComponent::class)
object PlatformNetworkModule {
@Provides
@Singleton
fun providesFillAssistService(
bitwardenServiceClient: BitwardenServiceClient,
): FillAssistService = bitwardenServiceClient.fillAssistService
@Provides
@Singleton
fun providesConfigService(

View File

@@ -11,6 +11,11 @@ import timber.log.Timber
abstract class BaseSdkSource(
protected val sdkClientManager: SdkClientManager,
) {
/**
* Helper function to retrieve the global [Client] synchronously.
*/
protected val globalClient get() = sdkClientManager.globalClient
/**
* Helper function to retrieve the [Client] associated with the given [userId].
*/

View File

@@ -1,7 +1,7 @@
package com.x8bit.bitwarden.data.platform.manager
import com.bitwarden.network.model.PolicyTypeJson
import com.bitwarden.network.model.SyncResponseJson
import com.bitwarden.policies.PolicyType
import com.bitwarden.policies.PolicyView
import kotlinx.coroutines.flow.Flow
/**
@@ -11,20 +11,20 @@ interface PolicyManager {
/**
* Returns a flow of all the active policies of the given type.
*/
fun getActivePoliciesFlow(type: PolicyTypeJson): Flow<List<SyncResponseJson.Policy>>
fun getActivePoliciesFlow(type: PolicyType): Flow<List<PolicyView>>
/**
* Get all the policies of the given [type] that are enabled and applicable to the user.
*/
fun getActivePolicies(type: PolicyTypeJson): List<SyncResponseJson.Policy>
fun getActivePolicies(type: PolicyType): List<PolicyView>
/**
* Get all the policies of the given [type] that are enabled and applicable to the [userId].
*/
fun getUserPolicies(
userId: String,
type: PolicyTypeJson,
): List<SyncResponseJson.Policy>
type: PolicyType,
): List<PolicyView>
/**
* Get the organization id of the personal ownership policy.

View File

@@ -1,17 +1,24 @@
package com.x8bit.bitwarden.data.platform.manager
import com.bitwarden.network.model.OrganizationStatusType
import com.bitwarden.network.model.OrganizationType
import com.bitwarden.network.model.PolicyTypeJson
import com.bitwarden.network.model.SyncResponseJson
import com.bitwarden.core.data.manager.model.FlagKey
import com.bitwarden.organizations.OrganizationUserStatusType
import com.bitwarden.organizations.OrganizationUserType
import com.bitwarden.policies.OrganizationUserPolicyContext
import com.bitwarden.policies.PolicyType
import com.bitwarden.policies.PolicyView
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
import com.x8bit.bitwarden.data.auth.datasource.sdk.AuthSdkSource
import com.x8bit.bitwarden.data.auth.repository.util.activeUserIdChangesFlow
import com.x8bit.bitwarden.data.vault.repository.util.toSdkOrganizationPolicyContext
import com.x8bit.bitwarden.data.vault.repository.util.toSdkPolicyViews
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.flow.map
/**
* The default [PolicyManager] implementation. This class is responsible for
@@ -19,114 +26,151 @@ import kotlinx.coroutines.flow.mapNotNull
*/
class PolicyManagerImpl(
private val authDiskSource: AuthDiskSource,
private val authSdkSource: AuthSdkSource,
private val featureFlagManager: FeatureFlagManager,
) : PolicyManager {
@OptIn(ExperimentalCoroutinesApi::class)
override fun getActivePoliciesFlow(type: PolicyTypeJson): Flow<List<SyncResponseJson.Policy>> =
override fun getActivePoliciesFlow(type: PolicyType): Flow<List<PolicyView>> =
authDiskSource
.activeUserIdChangesFlow
.flatMapLatest { activeUserId ->
activeUserId
?.let { userId ->
authDiskSource
.getPoliciesFlow(userId)
.mapNotNull {
filterPolicies(
userId = userId,
type = type,
policies = it,
)
}
}
?.let { userId -> getAppliedPolicyViewsFlow(userId = userId, type = type) }
?: emptyFlow()
}
.distinctUntilChanged()
override fun getActivePolicies(type: PolicyTypeJson): List<SyncResponseJson.Policy> =
override fun getActivePolicies(type: PolicyType): List<PolicyView> =
authDiskSource
.userState
?.activeUserId
?.let { userId ->
filterPolicies(
userId = userId,
type = type,
policies = authDiskSource.getPolicies(userId = userId),
)
}
?: emptyList()
?.let { userId -> getUserPolicies(userId = userId, type = type) }
.orEmpty()
override fun getUserPolicies(
userId: String,
type: PolicyTypeJson,
): List<SyncResponseJson.Policy> =
type: PolicyType,
): List<PolicyView> =
this
.filterPolicies(
userId = userId,
type = type,
policies = authDiskSource.getPolicies(userId = userId),
policies = authDiskSource
.getPolicies(userId = userId)
?.toSdkPolicyViews(),
organizations = authDiskSource
.getOrganizations(userId = userId)
?.map {
OrganizationPolicyData(
organizationUserPolicyContext = it.toSdkOrganizationPolicyContext(),
organizationShouldUsePolicies = it.permissions.shouldManagePolicies,
)
},
isPoliciesInAcceptedStateEnabled = featureFlagManager
.getFeatureFlag(key = FlagKey.PoliciesInAcceptedState),
)
.orEmpty()
override fun getPersonalOwnershipPolicyOrganizationId(): String? =
this
.getActivePolicies(PolicyTypeJson.PERSONAL_OWNERSHIP)
.getActivePolicies(type = PolicyType.ORGANIZATION_DATA_OWNERSHIP)
.sortedBy { it.revisionDate }
.firstOrNull()
?.organizationId
/**
* A helper method to filter policies.
*/
private fun filterPolicies(
private fun getAppliedPolicyViewsFlow(
userId: String,
type: PolicyTypeJson,
policies: List<SyncResponseJson.Policy>?,
): List<SyncResponseJson.Policy>? {
policies ?: return null
if (policies.isEmpty()) return emptyList()
// Get a list of the user's organizations that enforce policies.
val organizationIdsWithActivePolicies = authDiskSource
.getOrganizations(userId)
?.filter {
it.shouldUsePolicies &&
it.status >= OrganizationStatusType.ACCEPTED &&
!isOrganizationExemptFromPolicies(it, type)
}
?.map { it.id }
.orEmpty()
// Filter the policies based on the type, whether the policy is active,
// and whether the organization rules except the user from the policy.
return policies.filter {
it.type == type &&
it.isEnabled &&
organizationIdsWithActivePolicies.contains(it.organizationId)
}
type: PolicyType,
): Flow<List<PolicyView>> = combine(
authDiskSource
.getPoliciesFlow(userId = userId)
.map { it?.toSdkPolicyViews() },
authDiskSource
.getOrganizationsFlow(userId = userId)
.map { organizations ->
organizations?.map {
OrganizationPolicyData(
organizationUserPolicyContext = it.toSdkOrganizationPolicyContext(),
organizationShouldUsePolicies = it.permissions.shouldManagePolicies,
)
}
},
featureFlagManager.getFeatureFlagFlow(key = FlagKey.PoliciesInAcceptedState),
) { policies, organizations, isEnabled ->
filterPolicies(
type = type,
policies = policies,
organizations = organizations,
isPoliciesInAcceptedStateEnabled = isEnabled,
)
}
// We do not have any policies yet if it is null, so do not emit at all.
.filterNotNull()
private fun filterPolicies(
type: PolicyType,
policies: List<PolicyView>?,
organizations: List<OrganizationPolicyData>?,
isPoliciesInAcceptedStateEnabled: Boolean,
): List<PolicyView>? =
when {
policies == null -> null
policies.isEmpty() -> emptyList()
isPoliciesInAcceptedStateEnabled -> {
authSdkSource
.filterPolicies(
policies = policies,
policyType = type,
organizations = organizations
?.map { it.organizationUserPolicyContext }
.orEmpty(),
)
.getOrElse { emptyList() }
}
else -> {
// Legacy flow
val organizationIdsWithActivePolicies = organizations
?.filter {
@Suppress("MaxLineLength")
it.organizationUserPolicyContext.usePolicies &&
it.organizationUserPolicyContext.status >= OrganizationUserStatusType.ACCEPTED &&
!it.isOrganizationExemptFromPolicies(policyType = type)
}
?.map { it.organizationUserPolicyContext.id }
.orEmpty()
return policies.filter {
it.type == type &&
it.enabled &&
organizationIdsWithActivePolicies.contains(it.organizationId)
}
}
}
/**
* A helper method to determine if the organization is exempt from policies.
*/
private fun isOrganizationExemptFromPolicies(
organization: SyncResponseJson.Profile.Organization,
policyType: PolicyTypeJson,
private fun OrganizationPolicyData.isOrganizationExemptFromPolicies(
policyType: PolicyType,
): Boolean =
when (policyType) {
PolicyTypeJson.MAXIMUM_VAULT_TIMEOUT -> {
organization.type == OrganizationType.OWNER
PolicyType.MAXIMUM_VAULT_TIMEOUT -> {
this.organizationUserPolicyContext.role == OrganizationUserType.OWNER
}
PolicyTypeJson.PASSWORD_GENERATOR,
PolicyTypeJson.REMOVE_UNLOCK_WITH_PIN,
PolicyTypeJson.RESTRICT_ITEM_TYPES,
-> {
false
}
PolicyType.PASSWORD_GENERATOR,
PolicyType.REMOVE_UNLOCK_WITH_PIN,
PolicyType.RESTRICTED_ITEM_TYPES,
-> false
else -> {
(organization.type == OrganizationType.OWNER ||
organization.type == OrganizationType.ADMIN) ||
organization.permissions.shouldManagePolicies
this.organizationUserPolicyContext.role == OrganizationUserType.OWNER ||
this.organizationUserPolicyContext.role == OrganizationUserType.ADMIN ||
this.organizationShouldUsePolicies
}
}
}
private data class OrganizationPolicyData(
val organizationUserPolicyContext: OrganizationUserPolicyContext,
val organizationShouldUsePolicies: Boolean,
)

View File

@@ -7,6 +7,13 @@ import com.bitwarden.sdk.Client
*/
interface SdkClientManager {
/**
* Synchronously returns a [Client] that is unassociated with any user. It cannot be used for
* anything that performs a network requests. If the client is not yet ready, this will block
* until it is ready.
*/
val globalClient: Client
/**
* Returns the cached [Client] instance for the given [userId], otherwise creates and caches
* a new one and returns it.

View File

@@ -1,17 +1,24 @@
package com.x8bit.bitwarden.data.platform.manager
import android.os.Build
import com.bitwarden.core.data.manager.dispatcher.DispatcherManager
import com.bitwarden.core.data.util.concurrentMapOf
import com.bitwarden.core.util.isBuildVersionAtLeast
import com.bitwarden.data.manager.NativeLibraryManager
import com.bitwarden.sdk.Client
import com.x8bit.bitwarden.data.platform.manager.sdk.SdkPlatformApiFactory
import com.x8bit.bitwarden.data.platform.manager.sdk.SdkRepositoryFactory
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.async
import kotlinx.coroutines.runBlocking
/**
* Primary implementation of [SdkClientManager].
*/
class SdkClientManagerImpl(
nativeLibraryManager: NativeLibraryManager,
dispatcherManager: DispatcherManager,
sdkRepoFactory: SdkRepositoryFactory,
sdkPlatformApiFactory: SdkPlatformApiFactory,
private val featureFlagManager: FeatureFlagManager,
@@ -38,7 +45,9 @@ class SdkClientManagerImpl(
}
},
) : SdkClientManager {
private val userIdToClientMap = mutableMapOf<String, Client>()
private val userIdToClientMap = concurrentMapOf<String, Client>()
private val ioScope = CoroutineScope(context = dispatcherManager.io)
private val globalClientDeferred: Deferred<Client>
init {
// The SDK requires access to Android APIs that were not made public until API 31. In order
@@ -47,8 +56,13 @@ class SdkClientManagerImpl(
if (!isBuildVersionAtLeast(Build.VERSION_CODES.S)) {
nativeLibraryManager.loadLibrary("bitwarden_uniffi")
}
// Initialize this now, so that we can access it synchronously later on.
globalClientDeferred = ioScope.async { clientProvider(null, null) }
}
override val globalClient: Client
get() = runBlocking { globalClientDeferred.await() }
override suspend fun getOrCreateClient(
userId: String,
): Client = userIdToClientMap.getOrPut(key = userId) { clientProvider(userId, null) }

View File

@@ -19,6 +19,7 @@ import com.bitwarden.network.model.BitwardenServiceClientConfig
import com.bitwarden.network.service.EventService
import com.bitwarden.network.service.PushService
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
import com.x8bit.bitwarden.data.auth.datasource.sdk.AuthSdkSource
import com.x8bit.bitwarden.data.auth.manager.AddTotpItemFromAuthenticatorManager
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.autofill.accessibility.manager.AccessibilityEnabledManager
@@ -220,11 +221,13 @@ object PlatformManagerModule {
@Provides
@Singleton
fun provideSdkClientManager(
dispatcherManager: DispatcherManager,
featureFlagManager: FeatureFlagManager,
nativeLibraryManager: NativeLibraryManager,
sdkRepositoryFactory: SdkRepositoryFactory,
sdkPlatformApiFactory: SdkPlatformApiFactory,
): SdkClientManager = SdkClientManagerImpl(
dispatcherManager = dispatcherManager,
featureFlagManager = featureFlagManager,
nativeLibraryManager = nativeLibraryManager,
sdkRepoFactory = sdkRepositoryFactory,
@@ -262,8 +265,12 @@ object PlatformManagerModule {
@Singleton
fun providePolicyManager(
authDiskSource: AuthDiskSource,
authSdkSource: AuthSdkSource,
featureFlagManager: FeatureFlagManager,
): PolicyManager = PolicyManagerImpl(
authDiskSource = authDiskSource,
authSdkSource = authSdkSource,
featureFlagManager = featureFlagManager,
)
@Provides

View File

@@ -6,8 +6,12 @@ import com.bitwarden.network.BitwardenServiceClient
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
private const val ENVIRONMENT_DEBOUNCE_TIMEOUT_MS: Long = 500L
@@ -26,9 +30,11 @@ class NetworkConfigManagerImpl(
private val collectionScope = CoroutineScope(dispatcherManager.unconfined)
init {
@Suppress("OPT_IN_USAGE")
environmentRepository
.environmentStateFlow
@OptIn(FlowPreview::class)
combine(
environmentRepository.environmentStateFlow,
authRepository.userStateFlow.map { it?.activeUserId }.distinctUntilChanged(),
) { _, _ -> }
.debounce(timeoutMillis = ENVIRONMENT_DEBOUNCE_TIMEOUT_MS)
.onEach { _ ->
// This updates the stored service configuration by performing a network request.

View File

@@ -1,6 +1,6 @@
package com.x8bit.bitwarden.data.platform.manager.util
import com.bitwarden.network.model.PolicyTypeJson
import com.bitwarden.policies.PolicyType
import com.x8bit.bitwarden.data.auth.repository.model.PolicyInformation
import com.x8bit.bitwarden.data.auth.repository.util.policyInformation
import com.x8bit.bitwarden.data.platform.manager.PolicyManager
@@ -12,7 +12,7 @@ import kotlinx.coroutines.flow.map
*/
inline fun <reified T : PolicyInformation> PolicyManager.getActivePolicies(): List<T> =
this
.getActivePolicies(type = getPolicyTypeJson<T>())
.getActivePolicies(type = getPolicyType<T>())
.mapNotNull { it.policyInformation as? T }
/**
@@ -20,21 +20,21 @@ inline fun <reified T : PolicyInformation> PolicyManager.getActivePolicies(): Li
*/
inline fun <reified T : PolicyInformation> PolicyManager.getActivePoliciesFlow(): Flow<List<T>> =
this
.getActivePoliciesFlow(type = getPolicyTypeJson<T>())
.getActivePoliciesFlow(type = getPolicyType<T>())
.map { policies ->
policies.mapNotNull { policy -> policy.policyInformation as? T }
}
/**
* Helper method for mapping a specific [PolicyInformation] type to its [PolicyTypeJson]
* Helper method for mapping a specific [PolicyInformation] type to its [PolicyType]
* counterpart.
*/
inline fun <reified T : PolicyInformation> getPolicyTypeJson(): PolicyTypeJson =
inline fun <reified T : PolicyInformation> getPolicyType(): PolicyType =
when (T::class.java) {
PolicyInformation.MasterPassword::class.java -> PolicyTypeJson.MASTER_PASSWORD
PolicyInformation.PasswordGenerator::class.java -> PolicyTypeJson.PASSWORD_GENERATOR
PolicyInformation.SendOptions::class.java -> PolicyTypeJson.SEND_OPTIONS
PolicyInformation.VaultTimeout::class.java -> PolicyTypeJson.MAXIMUM_VAULT_TIMEOUT
PolicyInformation.MasterPassword::class.java -> PolicyType.MASTER_PASSWORD
PolicyInformation.PasswordGenerator::class.java -> PolicyType.PASSWORD_GENERATOR
PolicyInformation.SendOptions::class.java -> PolicyType.SEND_OPTIONS
PolicyInformation.VaultTimeout::class.java -> PolicyType.MAXIMUM_VAULT_TIMEOUT
else -> {
throw IllegalStateException(
@@ -43,9 +43,10 @@ inline fun <reified T : PolicyInformation> getPolicyTypeJson(): PolicyTypeJson =
)
}
}
/**
* Helper method for verifying if user has enabled the restrict item policy.
*/
fun PolicyManager.hasRestrictItemTypes(): Boolean =
getActivePolicies(type = PolicyTypeJson.RESTRICT_ITEM_TYPES)
.any { it.isEnabled }
getActivePolicies(type = PolicyType.RESTRICTED_ITEM_TYPES)
.any { it.enabled }

View File

@@ -31,4 +31,6 @@ class BaseUrlsProviderImpl(
.toEnvironmentUrlsOrDefault()
.environmentUrlData
.baseEventsUrl
override fun getBaseFillAssistUrl(): String? = environmentDiskSource.fillAssistRulesUrl
}

View File

@@ -10,7 +10,6 @@ import com.bitwarden.core.data.util.flatMap
import com.bitwarden.data.repository.util.toEnvironmentUrlsOrDefault
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.repository.util.toAccountCryptographicState
import com.x8bit.bitwarden.data.auth.repository.util.toSdkParams
import com.x8bit.bitwarden.data.vault.datasource.disk.VaultDiskSource
import com.x8bit.bitwarden.data.vault.datasource.sdk.ScopedVaultSdkSource
@@ -135,20 +134,17 @@ class AuthenticatorBridgeRepositoryImpl(
account: AccountJson,
decryptedUserKey: String,
): VaultUnlockResult {
val accountKeys = authDiskSource.getAccountKeys(userId = userId)
val privateKey = accountKeys?.publicKeyEncryptionKeyPair?.wrappedPrivateKey
?: authDiskSource.getPrivateKey(userId = userId)
val accountCryptographicState = authDiskSource
.getAccountCryptographicState(userId = userId)
?: return VaultUnlockResult.InvalidStateError(
MissingPropertyException("Private key"),
error = MissingPropertyException("Account Cryptographic State"),
)
return scopedVaultSdkSource
.initializeCrypto(
userId = userId,
request = InitUserCryptoRequest(
accountCryptographicState = accountKeys.toAccountCryptographicState(
privateKey = privateKey,
),
accountCryptographicState = accountCryptographicState,
userId = userId,
kdfParams = account.profile.toSdkParams(),
email = account.profile.email,

View File

@@ -187,6 +187,16 @@ interface SettingsRepository : FlightRecorderManager {
*/
val isScreenCaptureAllowedStateFlow: StateFlow<Boolean>
/**
* Whether the accessibility disclaimer has been displayed to the user.
*/
val hasShownAccessibilityDisclaimerFlow: StateFlow<Boolean>
/**
* Stores that the accessibility disclaimer has been displayed to the user.
*/
fun accessibilityDisclaimerHasBeenShown()
/**
* Disables autofill if it is currently enabled.
*/

View File

@@ -2,10 +2,11 @@ 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.network.model.PolicyTypeJson
import com.bitwarden.network.model.SyncResponseJson
import com.bitwarden.policies.PolicyType
import com.bitwarden.policies.PolicyView
import com.bitwarden.ui.platform.feature.settings.appearance.model.AppTheme
import com.x8bit.bitwarden.BuildConfig
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
@@ -51,6 +52,7 @@ 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,
@@ -372,13 +374,28 @@ class SettingsRepositoryImpl(
initialValue = isScreenCaptureAllowed,
)
override val hasShownAccessibilityDisclaimerFlow: StateFlow<Boolean>
get() = settingsDiskSource
.hasShownAccessibilityDisclaimerFlow
.map { buildInfoManager.isFdroid || it ?: false }
.stateIn(
scope = unconfinedScope,
started = SharingStarted.Lazily,
initialValue = buildInfoManager.isFdroid ||
settingsDiskSource.hasShownAccessibilityDisclaimer ?: false,
)
init {
policyManager
.getActivePoliciesFlow(type = PolicyTypeJson.MAXIMUM_VAULT_TIMEOUT)
.getActivePoliciesFlow(type = PolicyType.MAXIMUM_VAULT_TIMEOUT)
.onEach { updateVaultUnlockSettingsIfNecessary(it) }
.launchIn(unconfinedScope)
}
override fun accessibilityDisclaimerHasBeenShown() {
settingsDiskSource.hasShownAccessibilityDisclaimer = true
}
override fun disableAutofill() {
autofillManager.disableAutofillServices()
@@ -676,7 +693,7 @@ class SettingsRepositoryImpl(
* settings to determine whether to update the user's settings.
*/
private fun updateVaultUnlockSettingsIfNecessary(
policies: List<SyncResponseJson.Policy>,
policies: List<PolicyView>,
) {
// The vault timeout policy can only be implemented in organizations that have
// the single organization policy, meaning that if this is enabled, the user is

View File

@@ -1,6 +1,7 @@
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
@@ -67,6 +68,7 @@ object PlatformRepositoryModule {
autofillManager: AutofillManager,
autofillEnabledManager: AutofillEnabledManager,
authDiskSource: AuthDiskSource,
buildInfoManager: BuildInfoManager,
settingsDiskSource: SettingsDiskSource,
vaultSdkSource: VaultSdkSource,
accessibilityEnabledManager: AccessibilityEnabledManager,
@@ -78,6 +80,7 @@ object PlatformRepositoryModule {
autofillManager = autofillManager,
autofillEnabledManager = autofillEnabledManager,
authDiskSource = authDiskSource,
buildInfoManager = buildInfoManager,
settingsDiskSource = settingsDiskSource,
vaultSdkSource = vaultSdkSource,
accessibilityEnabledManager = accessibilityEnabledManager,

View File

@@ -23,23 +23,23 @@ private fun EnvironmentUrlDataJson.authTabData(
kind: String,
): AuthTabData = when (this.environmentRegion) {
EnvironmentRegion.UNITED_STATES -> {
// TODO: PM-26577 Update this to use a "HttpsScheme"
AuthTabData.CustomScheme(
callbackUrl = "bitwarden://$kind-callback",
AuthTabData.HttpsScheme(
host = "bitwarden.com",
path = "$kind-callback",
)
}
EnvironmentRegion.EUROPEAN_UNION -> {
// TODO: PM-26577 Update this to use a "HttpsScheme"
AuthTabData.CustomScheme(
callbackUrl = "bitwarden://$kind-callback",
AuthTabData.HttpsScheme(
host = "bitwarden.eu",
path = "$kind-callback",
)
}
EnvironmentRegion.INTERNAL -> {
// TODO: PM-26577 Update this to use a "HttpsScheme"
AuthTabData.CustomScheme(
callbackUrl = "bitwarden://$kind-callback",
AuthTabData.HttpsScheme(
host = "bitwarden.pw",
path = "$kind-callback",
)
}

View File

@@ -1,11 +1,13 @@
package com.x8bit.bitwarden.data.tools.generator.datasource.sdk
import com.bitwarden.core.data.manager.dispatcher.DispatcherManager
import com.bitwarden.generators.PassphraseGeneratorRequest
import com.bitwarden.generators.PasswordGeneratorRequest
import com.bitwarden.generators.UsernameGeneratorRequest
import com.bitwarden.sdk.GeneratorClients
import com.x8bit.bitwarden.data.platform.datasource.sdk.BaseSdkSource
import com.x8bit.bitwarden.data.platform.manager.SdkClientManager
import kotlinx.coroutines.withContext
/**
* Implementation of [GeneratorSdkSource] that delegates password generation.
@@ -14,6 +16,7 @@ import com.x8bit.bitwarden.data.platform.manager.SdkClientManager
* [GeneratorClients] provided by the Bitwarden SDK.
*/
class GeneratorSdkSourceImpl(
private val dispatcherManager: DispatcherManager,
sdkClientManager: SdkClientManager,
) : BaseSdkSource(sdkClientManager = sdkClientManager),
GeneratorSdkSource {
@@ -51,6 +54,8 @@ class GeneratorSdkSourceImpl(
override suspend fun generateForwardedServiceEmail(
request: UsernameGeneratorRequest.Forwarded,
): Result<String> = runCatchingWithLogs {
useClient { generators().username(request) }
withContext(context = dispatcherManager.io) {
useClient { generators().username(request) }
}
}
}

View File

@@ -1,5 +1,6 @@
package com.x8bit.bitwarden.data.tools.generator.datasource.sdk.di
import com.bitwarden.core.data.manager.dispatcher.DispatcherManager
import com.x8bit.bitwarden.data.platform.manager.SdkClientManager
import com.x8bit.bitwarden.data.tools.generator.datasource.sdk.GeneratorSdkSource
import com.x8bit.bitwarden.data.tools.generator.datasource.sdk.GeneratorSdkSourceImpl
@@ -19,6 +20,10 @@ object GeneratorSdkModule {
@Provides
@Singleton
fun provideGeneratorSdkSource(
dispatcherManager: DispatcherManager,
sdkClientManager: SdkClientManager,
): GeneratorSdkSource = GeneratorSdkSourceImpl(sdkClientManager = sdkClientManager)
): GeneratorSdkSource = GeneratorSdkSourceImpl(
dispatcherManager = dispatcherManager,
sdkClientManager = sdkClientManager,
)
}

View File

@@ -38,7 +38,6 @@ import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.time.Clock
import javax.inject.Singleton
@@ -55,7 +54,7 @@ class GeneratorRepositoryImpl(
private val vaultSdkSource: VaultSdkSource,
private val passwordHistoryDiskSource: PasswordHistoryDiskSource,
private val reviewPromptManager: ReviewPromptManager,
private val dispatcherManager: DispatcherManager,
dispatcherManager: DispatcherManager,
) : GeneratorRepository {
private val scope = CoroutineScope(dispatcherManager.io)
@@ -193,8 +192,9 @@ class GeneratorRepositoryImpl(
override suspend fun generateForwardedServiceUsername(
forwardedServiceGeneratorRequest: UsernameGeneratorRequest.Forwarded,
): GeneratedForwardedServiceUsernameResult = withContext(dispatcherManager.io) {
generatorSdkSource.generateForwardedServiceEmail(forwardedServiceGeneratorRequest)
): GeneratedForwardedServiceUsernameResult =
generatorSdkSource
.generateForwardedServiceEmail(forwardedServiceGeneratorRequest)
.fold(
onSuccess = { generatedEmail ->
GeneratedForwardedServiceUsernameResult.Success(generatedEmail)
@@ -203,7 +203,6 @@ class GeneratorRepositoryImpl(
GeneratedForwardedServiceUsernameResult.InvalidRequest(it.message, error = it)
},
)
}
override fun getPasscodeGenerationOptions(): PasscodeGenerationOptions? {
val userId = authDiskSource.userState?.activeUserId

View File

@@ -4,6 +4,7 @@ import com.bitwarden.core.data.manager.dispatcher.DispatcherManager
import com.bitwarden.core.data.repository.util.bufferedMutableSharedFlow
import com.bitwarden.core.data.util.decodeFromStringWithErrorCallback
import com.bitwarden.network.model.SyncResponseJson
import com.bitwarden.ui.platform.base.util.orNullIfBlank
import com.x8bit.bitwarden.data.vault.datasource.disk.dao.CiphersDao
import com.x8bit.bitwarden.data.vault.datasource.disk.dao.CollectionsDao
import com.x8bit.bitwarden.data.vault.datasource.disk.dao.DomainsDao
@@ -134,7 +135,7 @@ class VaultDiskSourceImpl(
.filter {
// A safety-check since after the DB migration, we will temporarily think
// all ciphers contain a totp code
it.login?.totp != null
it.login?.totp.orNullIfBlank() != null
}
}
}
@@ -166,7 +167,7 @@ class VaultDiskSourceImpl(
CipherEntity(
id = cipher.id,
userId = userId,
hasTotp = cipher.login?.totp != null,
hasTotp = cipher.login?.totp.orNullIfBlank() != null,
cipherType = json.encodeToString(cipher.type),
cipherJson = json.encodeToString(cipher),
organizationId = cipher.organizationId,

View File

@@ -21,6 +21,7 @@ class ScopedVaultSdkSourceImpl(
sdkPlatformApiFactory: SdkPlatformApiFactory,
vaultSdkSource: VaultSdkSource = VaultSdkSourceImpl(
sdkClientManager = SdkClientManagerImpl(
dispatcherManager = dispatcherManager,
// We do not want to have the real NativeLibraryManager used here to avoid
// initializing the library twice.
nativeLibraryManager = object : NativeLibraryManager {

View File

@@ -190,9 +190,9 @@ class VaultSdkSourceImpl(
): Result<InitializeCryptoResult> =
runCatchingWithLogs {
try {
getClient(userId = userId)
.crypto()
.initializeUserCrypto(req = request)
withContext(context = dispatcherManager.io) {
getClient(userId = userId).crypto().initializeUserCrypto(req = request)
}
InitializeCryptoResult.Success
} catch (exception: BitwardenException) {
// The only truly expected error from the SDK is an incorrect key/password.

View File

@@ -130,7 +130,6 @@ interface CipherManager {
*/
suspend fun updateCipherCollections(
cipherId: String,
cipherView: CipherView,
collectionIds: List<String>,
): ShareCipherResult

View File

@@ -442,7 +442,6 @@ class CipherManagerImpl(
override suspend fun updateCipherCollections(
cipherId: String,
cipherView: CipherView,
collectionIds: List<String>,
): ShareCipherResult {
val userId = activeUserId ?: return ShareCipherResult.Error(error = NoActiveUserException())
@@ -451,17 +450,18 @@ class CipherManagerImpl(
cipherId = cipherId,
body = UpdateCipherCollectionsJsonRequest(collectionIds = collectionIds),
)
.flatMap {
vaultSdkSource.encryptCipher(
userId = userId,
cipherView = cipherView.copy(collectionIds = collectionIds),
)
}
.onSuccess { encryptionContext ->
vaultDiskSource.saveCipher(
userId = userId,
cipher = encryptionContext.toEncryptedNetworkCipherResponse(),
)
.onSuccess { response ->
response
.cipher
?.let {
// Save the updated cipher to disk.
vaultDiskSource.saveCipher(userId = userId, cipher = it)
}
?: run {
// The user no longer has any collection access to the cipher after
// the update, so remove it from disk.
vaultDiskSource.deleteCipher(userId = userId, cipherId = cipherId)
}
}
.fold(
onSuccess = { ShareCipherResult.Success },

View File

@@ -28,7 +28,6 @@ import com.x8bit.bitwarden.data.auth.manager.UserLogoutManager
import com.x8bit.bitwarden.data.auth.repository.model.LogoutReason
import com.x8bit.bitwarden.data.auth.repository.model.UpdateKdfMinimumsResult
import com.x8bit.bitwarden.data.auth.repository.util.activeUserIdChangesFlow
import com.x8bit.bitwarden.data.auth.repository.util.toAccountCryptographicState
import com.x8bit.bitwarden.data.auth.repository.util.toSdkParams
import com.x8bit.bitwarden.data.auth.repository.util.userAccountTokens
import com.x8bit.bitwarden.data.auth.repository.util.userSwitchingChangesFlow
@@ -675,17 +674,14 @@ class VaultLockManagerImpl(
): VaultUnlockResult {
val account = authDiskSource.userState?.accounts?.get(userId)
?: return VaultUnlockResult.InvalidStateError(error = NoActiveUserException())
val accountKeys = authDiskSource.getAccountKeys(userId = userId)
val privateKey = accountKeys?.publicKeyEncryptionKeyPair?.wrappedPrivateKey
?: authDiskSource.getPrivateKey(userId = userId)
val accountCryptographicState = authDiskSource
.getAccountCryptographicState(userId = userId)
?: return VaultUnlockResult.InvalidStateError(
error = MissingPropertyException("Private key"),
error = MissingPropertyException("Account Cryptographic State"),
)
val organizationKeys = authDiskSource.getOrganizationKeys(userId = userId)
return unlockVault(
accountCryptographicState = accountKeys.toAccountCryptographicState(
privateKey = privateKey,
),
accountCryptographicState = accountCryptographicState,
userId = userId,
email = account.profile.email,
kdf = account.profile.toSdkParams(),

View File

@@ -8,10 +8,11 @@ import com.bitwarden.core.data.util.asFailure
import com.bitwarden.core.data.util.asSuccess
import com.bitwarden.core.data.util.flatMap
import com.bitwarden.network.model.BulkShareCiphersJsonRequest
import com.bitwarden.network.model.PolicyTypeJson
import com.bitwarden.network.model.OrganizationStatusType
import com.bitwarden.network.model.SyncResponseJson
import com.bitwarden.network.model.toCipherWithIdJsonRequest
import com.bitwarden.network.service.CiphersService
import com.bitwarden.policies.PolicyType
import com.bitwarden.vault.CipherView
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource
@@ -144,6 +145,7 @@ class VaultMigrationManagerImpl(
val orgName = authDiskSource
.getOrganizations(userId = userId)
?.filter { it.status == OrganizationStatusType.CONFIRMED }
?.firstOrNull { it.id == orgId }
?.name
?: return@update VaultMigrationData.NoMigrationRequired
@@ -167,9 +169,7 @@ class VaultMigrationManagerImpl(
hasPersonalCiphers: Boolean,
isNetworkConnected: Boolean,
): Boolean =
policyManager
.getActivePolicies(PolicyTypeJson.PERSONAL_OWNERSHIP)
.any() &&
policyManager.getActivePolicies(PolicyType.ORGANIZATION_DATA_OWNERSHIP).any() &&
featureFlagManager.getFeatureFlag(FlagKey.MigrateMyVaultToMyItems) &&
isNetworkConnected &&
hasPersonalCiphers

View File

@@ -7,6 +7,7 @@ import com.bitwarden.core.data.repository.model.DataState
import com.bitwarden.core.data.repository.util.combineDataStates
import com.bitwarden.core.data.repository.util.map
import com.bitwarden.core.data.repository.util.updateToPendingOrLoading
import com.bitwarden.network.model.OrganizationStatusType
import com.bitwarden.network.model.SyncResponseJson
import com.bitwarden.network.service.SyncService
import com.bitwarden.network.util.isNoConnectionError
@@ -16,6 +17,7 @@ import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
import com.x8bit.bitwarden.data.auth.manager.UserLogoutManager
import com.x8bit.bitwarden.data.auth.manager.UserStateManager
import com.x8bit.bitwarden.data.auth.repository.model.LogoutReason
import com.x8bit.bitwarden.data.auth.repository.util.toAccountCryptographicState
import com.x8bit.bitwarden.data.auth.repository.util.toUpdatedUserStateJson
import com.x8bit.bitwarden.data.auth.repository.util.userSwitchingChangesFlow
import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource
@@ -376,9 +378,14 @@ class VaultSyncManagerImpl(
val profile = syncResponse.profile
val userId = profile.id
authDiskSource.apply {
storeUserKey(userId = userId, userKey = profile.key)
storePrivateKey(userId = userId, privateKey = profile.privateKey)
storeAccountKeys(userId = userId, accountKeys = profile.accountKeys)
storeAccountCryptographicState(
userId = userId,
accountCryptographicState = profile.privateKeyOrNull()?.let {
profile.accountKeys.toAccountCryptographicState(
privateKey = it,
)
},
)
storeOrganizationKeys(
userId = userId,
organizationKeys = profile.organizations
@@ -469,6 +476,9 @@ class VaultSyncManagerImpl(
data = collections.sortAlphabeticallyByTypeAndOrganization(
userOrganizations = authDiskSource
.getOrganizations(userId = userId)
?.filter { org ->
org.status == OrganizationStatusType.CONFIRMED
}
.orEmpty(),
),
)
@@ -536,6 +546,13 @@ class VaultSyncManagerImpl(
?: DataState.Loading
}
/**
* Convenience function to extract the private key from the [SyncResponseJson.Profile] response.
*/
private fun SyncResponseJson.Profile.privateKeyOrNull(): String? =
this.accountKeys?.publicKeyEncryptionKeyPair?.wrappedPrivateKey
?: this.privateKey
private fun <T> Throwable.toNetworkOrErrorState(
data: T?,
): DataState<T> =

View File

@@ -19,7 +19,6 @@ import com.bitwarden.vault.CipherType
import com.bitwarden.vault.CipherView
import com.bitwarden.vault.FolderView
import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource
import com.x8bit.bitwarden.data.auth.repository.util.toAccountCryptographicState
import com.x8bit.bitwarden.data.auth.repository.util.toSdkParams
import com.x8bit.bitwarden.data.autofill.util.login
import com.x8bit.bitwarden.data.platform.error.NoActiveUserException
@@ -42,6 +41,7 @@ import com.x8bit.bitwarden.data.vault.repository.model.GenerateTotpResult
import com.x8bit.bitwarden.data.vault.repository.model.ImportCredentialsResult
import com.x8bit.bitwarden.data.vault.repository.model.TotpCodeResult
import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockResult
import com.x8bit.bitwarden.data.vault.repository.model.onVaultUnlockSuccess
import com.x8bit.bitwarden.data.vault.repository.util.toEncryptedSdkCipher
import com.x8bit.bitwarden.data.vault.repository.util.toEncryptedSdkFolder
import com.x8bit.bitwarden.data.vault.repository.util.toSdkAccount
@@ -322,21 +322,19 @@ class VaultRepositoryImpl(
decryptedUserKey = decryptedUserKey,
),
)
.also {
if (it is VaultUnlockResult.Success) {
encryptedBiometricsKey?.let { key ->
// If this key is present, we store it and the associated IV for future use
// since we want to migrate the user to a more secure form of biometrics.
authDiskSource.storeUserBiometricUnlockKey(
userId = userId,
biometricsKey = key,
)
authDiskSource.storeUserBiometricInitVector(userId = userId, iv = cipher.iv)
}
pinProtectedUserKeyManager.deriveTemporaryPinProtectedUserKeyIfNecessary(
.onVaultUnlockSuccess {
encryptedBiometricsKey?.let { key ->
// If this key is present, we store it and the associated IV for future use
// since we want to migrate the user to a more secure form of biometrics.
authDiskSource.storeUserBiometricUnlockKey(
userId = userId,
biometricsKey = key,
)
authDiskSource.storeUserBiometricInitVector(userId = userId, iv = cipher.iv)
}
pinProtectedUserKeyManager.deriveTemporaryPinProtectedUserKeyIfNecessary(
userId = userId,
)
}
}
@@ -363,12 +361,10 @@ class VaultRepositoryImpl(
userId = userId,
initUserCryptoMethod = initUserCryptoMethod,
)
.also {
if (it is VaultUnlockResult.Success) {
pinProtectedUserKeyManager.deriveTemporaryPinProtectedUserKeyIfNecessary(
userId = userId,
)
}
.onVaultUnlockSuccess {
pinProtectedUserKeyManager.deriveTemporaryPinProtectedUserKeyIfNecessary(
userId = userId,
)
}
}
@@ -559,19 +555,14 @@ class VaultRepositoryImpl(
): VaultUnlockResult {
val account = authDiskSource.userState?.accounts?.get(userId)
?: return VaultUnlockResult.InvalidStateError(error = NoActiveUserException())
val accountKeys = authDiskSource.getAccountKeys(userId = userId)
val privateKey = accountKeys
?.publicKeyEncryptionKeyPair
?.wrappedPrivateKey
?: authDiskSource.getPrivateKey(userId = userId)
val accountCryptographicState = authDiskSource
.getAccountCryptographicState(userId = userId)
?: return VaultUnlockResult.InvalidStateError(
error = MissingPropertyException("Private key"),
error = MissingPropertyException("Account Cryptographic State"),
)
val organizationKeys = authDiskSource.getOrganizationKeys(userId = userId)
return vaultLockManager.unlockVault(
accountCryptographicState = accountKeys.toAccountCryptographicState(
privateKey = privateKey,
),
accountCryptographicState = accountCryptographicState,
userId = userId,
email = account.profile.email,
kdf = account.profile.toSdkParams(),

View File

@@ -46,3 +46,18 @@ sealed class VaultUnlockResult {
sealed interface VaultUnlockError {
val error: Throwable?
}
/**
* Invokes the [onSuccess] lambda as a side effect.
*/
inline fun VaultUnlockResult.onVaultUnlockSuccess(
onSuccess: () -> Unit,
): VaultUnlockResult = when (this) {
is VaultUnlockResult.AuthenticationError,
is VaultUnlockResult.BiometricDecodingError,
is VaultUnlockResult.GenericError,
is VaultUnlockResult.InvalidStateError,
-> this
is VaultUnlockResult.Success -> this.also { onSuccess() }
}

View File

@@ -70,8 +70,8 @@ fun Cipher.toEncryptedNetworkCipher(
key = key,
sshKey = sshKey?.toEncryptedNetworkSshKey(),
bankAccount = bankAccount?.toEncryptedNetworkBankAccount(),
driversLicense = null,
passport = null,
driversLicense = driversLicense?.toEncryptedNetworkDriversLicense(),
passport = passport?.toEncryptedNetworkPassport(),
archivedDate = archivedDate,
encryptedFor = encryptedFor,
)

View File

@@ -0,0 +1,47 @@
package com.x8bit.bitwarden.data.vault.repository.util
import com.bitwarden.network.model.OrganizationStatusType
import com.bitwarden.network.model.OrganizationType
import com.bitwarden.network.model.SyncResponseJson
import com.bitwarden.organizations.OrganizationUserStatusType
import com.bitwarden.organizations.OrganizationUserType
import com.bitwarden.policies.OrganizationUserPolicyContext
/**
* Converts a list of network [SyncResponseJson.Profile.Organization] models to a list of SDK
* [OrganizationUserPolicyContext].
*/
@Suppress("MaxLineLength")
fun List<SyncResponseJson.Profile.Organization>.toSdkOrganizationPolicyContexts(): List<OrganizationUserPolicyContext> =
this.map { it.toSdkOrganizationPolicyContext() }
/**
* Converts a network [SyncResponseJson.Profile.Organization] model to an SDK
* [OrganizationUserPolicyContext].
*/
@Suppress("MaxLineLength")
fun SyncResponseJson.Profile.Organization.toSdkOrganizationPolicyContext(): OrganizationUserPolicyContext =
OrganizationUserPolicyContext(
id = this.id,
status = this.status.toSdkOrganizationUserStatusType,
role = this.type.toSdkOrganizationUserType,
enabled = this.isEnabled,
usePolicies = this.shouldUsePolicies,
isProviderUser = this.isProviderUser,
)
private val OrganizationStatusType.toSdkOrganizationUserStatusType: OrganizationUserStatusType
get() = when (this) {
OrganizationStatusType.REVOKED -> OrganizationUserStatusType.REVOKED
OrganizationStatusType.INVITED -> OrganizationUserStatusType.INVITED
OrganizationStatusType.ACCEPTED -> OrganizationUserStatusType.ACCEPTED
OrganizationStatusType.CONFIRMED -> OrganizationUserStatusType.CONFIRMED
}
private val OrganizationType.toSdkOrganizationUserType: OrganizationUserType
get() = when (this) {
OrganizationType.OWNER -> OrganizationUserType.OWNER
OrganizationType.ADMIN -> OrganizationUserType.ADMIN
OrganizationType.USER -> OrganizationUserType.USER
OrganizationType.CUSTOM -> OrganizationUserType.CUSTOM
}

View File

@@ -0,0 +1,55 @@
package com.x8bit.bitwarden.data.vault.repository.util
import com.bitwarden.network.model.PolicyTypeJson
import com.bitwarden.network.model.SyncResponseJson
import com.bitwarden.policies.PolicyType
import com.bitwarden.policies.PolicyView
import kotlinx.serialization.json.Json
/**
* Converts a list of network [SyncResponseJson.Policy] models to a list of SDK [PolicyView].
*/
fun List<SyncResponseJson.Policy>.toSdkPolicyViews(): List<PolicyView> =
this.map { it.toSdkPolicyView() }
/**
* Converts a network [SyncResponseJson.Policy] model to an SDK [PolicyView].
*/
private fun SyncResponseJson.Policy.toSdkPolicyView(): PolicyView =
PolicyView(
organizationId = this.organizationId,
id = this.id,
type = this.type.toSdkPolicyType,
enabled = this.isEnabled,
data = this.data?.let { Json.encodeToString(it) },
revisionDate = this.revisionDate,
)
private val PolicyTypeJson.toSdkPolicyType: PolicyType
get() = when (this) {
PolicyTypeJson.TWO_FACTOR_AUTHENTICATION -> PolicyType.TWO_FACTOR_AUTHENTICATION
PolicyTypeJson.MASTER_PASSWORD -> PolicyType.MASTER_PASSWORD
PolicyTypeJson.PASSWORD_GENERATOR -> PolicyType.PASSWORD_GENERATOR
PolicyTypeJson.ONLY_ORG -> PolicyType.SINGLE_ORG
PolicyTypeJson.REQUIRE_SSO -> PolicyType.REQUIRE_SSO
PolicyTypeJson.PERSONAL_OWNERSHIP -> PolicyType.ORGANIZATION_DATA_OWNERSHIP
PolicyTypeJson.DISABLE_SEND -> PolicyType.DISABLE_SEND
PolicyTypeJson.SEND_OPTIONS -> PolicyType.SEND_OPTIONS
PolicyTypeJson.RESET_PASSWORD -> PolicyType.RESET_PASSWORD
PolicyTypeJson.MAXIMUM_VAULT_TIMEOUT -> PolicyType.MAXIMUM_VAULT_TIMEOUT
PolicyTypeJson.DISABLE_PERSONAL_VAULT_EXPORT -> PolicyType.DISABLE_PERSONAL_VAULT_EXPORT
PolicyTypeJson.ACTIVATE_AUTOFILL -> PolicyType.ACTIVATE_AUTOFILL
PolicyTypeJson.AUTOMATIC_APP_LOG_IN -> PolicyType.AUTOMATIC_APP_LOG_IN
PolicyTypeJson.FREE_FAMILIES_SPONSORSHIP_POLICY -> PolicyType.FREE_FAMILIES_SPONSORSHIP
PolicyTypeJson.REMOVE_UNLOCK_WITH_PIN -> PolicyType.REMOVE_UNLOCK_WITH_PIN
PolicyTypeJson.RESTRICT_ITEM_TYPES -> PolicyType.RESTRICTED_ITEM_TYPES
PolicyTypeJson.URI_MATCH_DEFAULTS -> PolicyType.URI_MATCH_DEFAULTS
PolicyTypeJson.AUTOTYPE_DEFAULT_SETTING -> PolicyType.AUTOTYPE_DEFAULT_SETTING
PolicyTypeJson.AUTOMATIC_USER_CONFIRMATION -> PolicyType.AUTOMATIC_USER_CONFIRMATION
PolicyTypeJson.BLOCK_CLAIMED_DOMAIN_ACCOUNT_CREATION -> {
PolicyType.BLOCK_CLAIMED_DOMAIN_ACCOUNT_CREATION
}
PolicyTypeJson.ORGANIZATION_USER_NOTIFICATION -> PolicyType.ORGANIZATION_USER_NOTIFICATION
PolicyTypeJson.SEND_CONTROLS -> PolicyType.SEND_CONTROLS
}

View File

@@ -1,34 +0,0 @@
package com.x8bit.bitwarden.data.vault.repository.util
import com.bitwarden.core.WrappedAccountCryptographicState
/**
* Creates a [WrappedAccountCryptographicState] based on the available cryptographic parameters.
*
* Returns [WrappedAccountCryptographicState.V2] if signing key, signed public key, and security
* state are all present, otherwise returns [WrappedAccountCryptographicState.V1].
*
* @param privateKey The user's wrapped private key.
* @param securityState The user's signed security state (V2 only).
* @param signingKey The user's wrapped signing key (V2 only).
* @param signedPublicKey The user's signed public key (V2 only).
*/
fun createWrappedAccountCryptographicState(
privateKey: String,
securityState: String?,
signingKey: String?,
signedPublicKey: String?,
): WrappedAccountCryptographicState {
return if (signingKey != null && securityState != null && signedPublicKey != null) {
WrappedAccountCryptographicState.V2(
privateKey = privateKey,
securityState = securityState,
signingKey = signingKey,
signedPublicKey = signedPublicKey,
)
} else {
WrappedAccountCryptographicState.V1(
privateKey = privateKey,
)
}
}

View File

@@ -1,11 +1,14 @@
package com.x8bit.bitwarden.ui.auth.feature.removepassword
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
@@ -14,14 +17,17 @@ import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.bitwarden.ui.platform.base.util.cardStyle
import com.bitwarden.ui.platform.base.util.standardHorizontalMargin
import com.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar
import com.bitwarden.ui.platform.components.button.BitwardenFilledButton
@@ -30,8 +36,11 @@ import com.bitwarden.ui.platform.components.dialog.BitwardenBasicDialog
import com.bitwarden.ui.platform.components.dialog.BitwardenLoadingDialog
import com.bitwarden.ui.platform.components.dialog.BitwardenTwoButtonDialog
import com.bitwarden.ui.platform.components.field.BitwardenPasswordField
import com.bitwarden.ui.platform.components.icon.BitwardenIcon
import com.bitwarden.ui.platform.components.icon.model.IconData
import com.bitwarden.ui.platform.components.model.CardStyle
import com.bitwarden.ui.platform.components.scaffold.BitwardenScaffold
import com.bitwarden.ui.platform.resource.BitwardenDrawable
import com.bitwarden.ui.platform.resource.BitwardenString
import com.bitwarden.ui.platform.theme.BitwardenTheme
import com.bitwarden.ui.util.asText
@@ -91,53 +100,41 @@ private fun RemovePasswordScreenContent(
modifier = modifier
.verticalScroll(rememberScrollState()),
) {
Spacer(modifier = Modifier.height(height = 12.dp))
Spacer(modifier = Modifier.height(height = 24.dp))
Text(
text = state.description(),
style = BitwardenTheme.typography.bodyMedium,
color = BitwardenTheme.colorScheme.text.primary,
textAlign = TextAlign.Center,
modifier = Modifier
.standardHorizontalMargin()
.fillMaxWidth(),
)
Spacer(modifier = Modifier.height(height = 12.dp))
Text(
text = state.labelOrg(),
style = BitwardenTheme.typography.bodyMedium,
color = BitwardenTheme.colorScheme.text.primary,
Spacer(modifier = Modifier.height(height = 24.dp))
RemovePasswordInfoColumn(
label = state.labelOrg(),
value = state.orgName?.invoke().orEmpty(),
icon = IconData.Local(iconRes = BitwardenDrawable.ic_organization),
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin()
.fillMaxWidth(),
.cardStyle(
cardStyle = CardStyle.Top(dividerPadding = 56.dp),
paddingBottom = 4.dp,
),
)
Text(
text = state.orgName?.invoke().orEmpty(),
style = BitwardenTheme.typography.bodyMedium,
color = BitwardenTheme.colorScheme.text.secondary,
RemovePasswordInfoColumn(
label = state.labelDomain(),
value = state.domainName?.invoke().orEmpty(),
icon = IconData.Local(iconRes = BitwardenDrawable.ic_globe),
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin()
.fillMaxWidth(),
.cardStyle(cardStyle = CardStyle.Bottom),
)
Spacer(modifier = Modifier.height(height = 12.dp))
Text(
text = state.labelDomain(),
style = BitwardenTheme.typography.bodyMedium,
color = BitwardenTheme.colorScheme.text.primary,
modifier = Modifier
.standardHorizontalMargin()
.fillMaxWidth(),
)
Text(
text = state.domainName?.invoke().orEmpty(),
style = BitwardenTheme.typography.bodyMedium,
color = BitwardenTheme.colorScheme.text.secondary,
modifier = Modifier
.standardHorizontalMargin()
.fillMaxWidth(),
)
Spacer(modifier = Modifier.height(16.dp))
Spacer(modifier = Modifier.height(height = 24.dp))
BitwardenPasswordField(
label = stringResource(id = BitwardenString.master_password),
@@ -179,6 +176,43 @@ private fun RemovePasswordScreenContent(
}
}
@Composable
private fun RemovePasswordInfoColumn(
label: String,
value: String,
icon: IconData,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier.defaultMinSize(minHeight = 60.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Spacer(modifier = Modifier.width(width = 12.dp))
BitwardenIcon(
iconData = icon,
tint = BitwardenTheme.colorScheme.icon.secondary,
)
Spacer(modifier = Modifier.width(width = 12.dp))
Column(modifier = Modifier.fillMaxWidth()) {
Text(
text = label,
style = BitwardenTheme.typography.titleSmall,
color = BitwardenTheme.colorScheme.text.primary,
modifier = Modifier.fillMaxWidth(),
)
Spacer(modifier = Modifier.height(height = 4.dp))
Text(
text = value,
style = BitwardenTheme.typography.bodyMedium,
color = BitwardenTheme.colorScheme.text.secondary,
modifier = Modifier.fillMaxWidth(),
)
}
}
}
@Composable
private fun RemovePasswordDialogs(
dialogState: RemovePasswordState.DialogState?,

View File

@@ -32,12 +32,14 @@ class RemovePasswordViewModel @Inject constructor(
val org = authRepository.userStateFlow.value
?.activeAccount
?.organizations
?.firstOrNull { it.shouldUseKeyConnector }
?.firstOrNull { it.isKeyConnectorEnabled }
RemovePasswordState(
input = "",
description = BitwardenString.password_no_longer_required_confirm_domain.asText(),
labelOrg = BitwardenString.key_connector_organization.asText(),
description = BitwardenString
.your_organization_no_longer_requires_a_master_password
.asText(),
labelOrg = BitwardenString.organization.asText(),
orgName = org?.name?.asText(),
labelDomain = BitwardenString.key_connector_domain.asText(),
domainName = org?.keyConnectorUrl?.asText(),

View File

@@ -0,0 +1,40 @@
@file:OmitFromCoverage
package com.x8bit.bitwarden.ui.platform.feature.accessibilitydisclosure
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import com.bitwarden.annotation.OmitFromCoverage
import com.bitwarden.ui.platform.base.util.composableWithSlideTransitions
import kotlinx.serialization.Serializable
/**
* The type-safe route for the accessibility disclosure screen.
*/
@OmitFromCoverage
@Serializable
data object AccessibilityDisclosureRoute
/**
* Add the accessibility disclosure screen to the nav graph.
*/
fun NavGraphBuilder.accessibilityDisclosureDestination(
onDismiss: () -> Unit,
onSplashScreenRemoved: () -> Unit,
) {
composableWithSlideTransitions<AccessibilityDisclosureRoute> {
AccessibilityDisclosureScreen(onDismiss = onDismiss)
// If we are displaying the accessibility disclosure screen, then we can just hide
// the splash screen.
onSplashScreenRemoved()
}
}
/**
* Navigate to the accessibility disclosure screen.
*/
fun NavController.navigateToAccessibilityDisclosure() {
this.navigate(route = AccessibilityDisclosureRoute) {
launchSingleTop = true
}
}

View File

@@ -0,0 +1,158 @@
package com.x8bit.bitwarden.ui.platform.feature.accessibilitydisclosure
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.displayCutout
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.union
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ScaffoldDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import com.bitwarden.ui.platform.base.util.EventsEffect
import com.bitwarden.ui.platform.base.util.standardHorizontalMargin
import com.bitwarden.ui.platform.components.button.BitwardenFilledButton
import com.bitwarden.ui.platform.components.button.BitwardenOutlinedButton
import com.bitwarden.ui.platform.components.scaffold.BitwardenScaffold
import com.bitwarden.ui.platform.components.util.rememberVectorPainter
import com.bitwarden.ui.platform.composition.LocalExitManager
import com.bitwarden.ui.platform.manager.exit.ExitManager
import com.bitwarden.ui.platform.resource.BitwardenDrawable
import com.bitwarden.ui.platform.resource.BitwardenString
import com.bitwarden.ui.platform.theme.BitwardenTheme
/**
* Top-level composable for the Accessibility Disclosure screen.
*/
@Composable
fun AccessibilityDisclosureScreen(
onDismiss: () -> Unit,
viewModel: AccessibilityDisclosureViewModel = hiltViewModel(),
exitManager: ExitManager = LocalExitManager.current,
) {
EventsEffect(viewModel = viewModel) { event ->
when (event) {
is AccessibilityDisclosureEvent.Dismiss -> onDismiss()
is AccessibilityDisclosureEvent.CloseApp -> exitManager.exitApplication()
}
}
BackHandler { viewModel.trySendAction(AccessibilityDisclosureAction.CloseAppClick) }
BitwardenScaffold(
contentWindowInsets = ScaffoldDefaults
.contentWindowInsets
.union(WindowInsets.displayCutout)
.only(WindowInsetsSides.Horizontal + WindowInsetsSides.Top),
) {
AccessibilityDisclosureContent(
onAcceptClick = {
viewModel.trySendAction(AccessibilityDisclosureAction.AcceptClicked)
},
onCloseAppClick = {
viewModel.trySendAction(AccessibilityDisclosureAction.CloseAppClick)
},
modifier = Modifier.fillMaxSize(),
)
}
}
@Composable
private fun AccessibilityDisclosureContent(
onAcceptClick: () -> Unit,
onCloseAppClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier.verticalScroll(state = rememberScrollState()),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Spacer(modifier = Modifier.height(height = 32.dp))
Image(
painter = rememberVectorPainter(id = BitwardenDrawable.ill_autofill),
contentDescription = null,
contentScale = ContentScale.FillHeight,
modifier = Modifier
.standardHorizontalMargin()
.size(size = 100.dp)
.fillMaxWidth(),
)
Spacer(modifier = Modifier.height(height = 24.dp))
Text(
text = stringResource(id = BitwardenString.accessibility_service_disclosure),
style = BitwardenTheme.typography.headlineSmall,
color = BitwardenTheme.colorScheme.text.primary,
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin(),
)
Spacer(modifier = Modifier.height(height = 12.dp))
Text(
text = stringResource(id = BitwardenString.accessibility_disclosure_start_up_text),
style = BitwardenTheme.typography.bodyMedium,
color = BitwardenTheme.colorScheme.text.primary,
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin(),
)
Spacer(modifier = Modifier.height(height = 24.dp))
BitwardenFilledButton(
label = stringResource(id = BitwardenString.accept),
onClick = onAcceptClick,
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin(),
)
Spacer(modifier = Modifier.height(height = 12.dp))
BitwardenOutlinedButton(
label = stringResource(id = BitwardenString.close_app),
onClick = onCloseAppClick,
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin(),
)
Spacer(modifier = Modifier.height(height = 16.dp))
Spacer(modifier = Modifier.navigationBarsPadding())
}
}
@Preview(showBackground = true)
@Composable
private fun AccessibilityDisclosureContent_preview() {
BitwardenTheme {
AccessibilityDisclosureContent(
onAcceptClick = {},
onCloseAppClick = {},
modifier = Modifier.fillMaxSize(),
)
}
}

View File

@@ -0,0 +1,74 @@
package com.x8bit.bitwarden.ui.platform.feature.accessibilitydisclosure
import android.os.Parcelable
import com.bitwarden.ui.platform.base.BaseViewModel
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.parcelize.Parcelize
import javax.inject.Inject
/**
* ViewModel for the Accessibility Disclosure screen.
*/
@HiltViewModel
class AccessibilityDisclosureViewModel @Inject constructor(
private val settingsRepository: SettingsRepository,
) : BaseViewModel<
AccessibilityDisclosureState,
AccessibilityDisclosureEvent,
AccessibilityDisclosureAction,
>(
initialState = AccessibilityDisclosureState,
) {
override fun handleAction(action: AccessibilityDisclosureAction) {
when (action) {
AccessibilityDisclosureAction.AcceptClicked -> handleAcceptClicked()
AccessibilityDisclosureAction.CloseAppClick -> handleCloseAppClick()
}
}
private fun handleAcceptClicked() {
settingsRepository.accessibilityDisclaimerHasBeenShown()
sendEvent(AccessibilityDisclosureEvent.Dismiss)
}
private fun handleCloseAppClick() {
sendEvent(AccessibilityDisclosureEvent.CloseApp)
}
}
/**
* State for the Accessibility Disclosure screen.
*/
@Parcelize
data object AccessibilityDisclosureState : Parcelable
/**
* Events for the Accessibility Disclosure screen.
*/
sealed class AccessibilityDisclosureEvent {
/**
* Navigate back, dismissing the screen.
*/
data object Dismiss : AccessibilityDisclosureEvent()
/**
* Closes the app.
*/
data object CloseApp : AccessibilityDisclosureEvent()
}
/**
* Actions for the Accessibility Disclosure screen.
*/
sealed class AccessibilityDisclosureAction {
/**
* User clicked the accept button.
*/
data object AcceptClicked : AccessibilityDisclosureAction()
/**
* User clicked the close app button.
*/
data object CloseAppClick : AccessibilityDisclosureAction()
}

View File

@@ -2,7 +2,7 @@ package com.x8bit.bitwarden.ui.platform.feature.debugmenu
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import com.bitwarden.ui.platform.base.util.composableWithPushTransitions
import com.bitwarden.ui.platform.base.util.composableWithSlideTransitions
import kotlinx.serialization.Serializable
/**
@@ -27,7 +27,7 @@ fun NavGraphBuilder.debugMenuDestination(
onNavigateBack: () -> Unit,
onSplashScreenRemoved: () -> Unit,
) {
composableWithPushTransitions<DebugRoute> {
composableWithSlideTransitions<DebugRoute> {
DebugMenuScreen(onNavigateBack = onNavigateBack)
// If we are displaying the debug screen, then we can just hide the splash screen.
onSplashScreenRemoved()

View File

@@ -0,0 +1,22 @@
package com.x8bit.bitwarden.ui.platform.feature.overlaynav
import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.composable
import kotlinx.serialization.Serializable
/**
* The type-safe route for the overlay navigation screen.
*/
@Serializable
data object OverlayNavRoute
/**
* Add the overlay navigation screen to the nav graph.
*/
fun NavGraphBuilder.overlayNavDestination(
onSplashScreenRemoved: () -> Unit,
) {
composable<OverlayNavRoute> {
OverlayNavScreen(onSplashScreenRemoved = onSplashScreenRemoved)
}
}

View File

@@ -0,0 +1,76 @@
package com.x8bit.bitwarden.ui.platform.feature.overlaynav
import androidx.compose.runtime.Composable
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.navigation.NavController
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import com.bitwarden.ui.platform.base.util.EventsEffect
import com.x8bit.bitwarden.ui.platform.components.util.rememberBitwardenNavController
import com.x8bit.bitwarden.ui.platform.feature.accessibilitydisclosure.accessibilityDisclosureDestination
import com.x8bit.bitwarden.ui.platform.feature.accessibilitydisclosure.navigateToAccessibilityDisclosure
import com.x8bit.bitwarden.ui.platform.feature.cookieacquisition.cookieAcquisitionDestination
import com.x8bit.bitwarden.ui.platform.feature.cookieacquisition.navigateToCookieAcquisition
import com.x8bit.bitwarden.ui.platform.feature.localnetworkaccess.localNetworkAccessDestination
import com.x8bit.bitwarden.ui.platform.feature.localnetworkaccess.navigateToLocalNetworkAccess
import com.x8bit.bitwarden.ui.platform.feature.rootnav.RootNavigationRoute
import com.x8bit.bitwarden.ui.platform.feature.rootnav.rootNavDestination
/**
* Controls the overlay [NavHost] for the app including the [rootNavDestination] and any screen
* that can appear on top of it without affecting its state.
*/
@Composable
fun OverlayNavScreen(
viewModel: OverlayNavViewModel = hiltViewModel(),
navController: NavHostController = rememberBitwardenNavController(name = "OverlayNavScreen"),
onSplashScreenRemoved: () -> Unit,
) {
OverlayNavEventsEffect(
viewModel = viewModel,
navController = navController,
)
NavHost(
navController = navController,
startDestination = RootNavigationRoute,
) {
// This is the overlay level of navigation that sits above the root nav. These screens
// can appear on top of the rest of the app without interacting with the state-based
// navigation used by RootNavScreen (which also exists here).
rootNavDestination(onSplashScreenRemoved = onSplashScreenRemoved)
cookieAcquisitionDestination(
onDismiss = { navController.popBackStack() },
onSplashScreenRemoved = onSplashScreenRemoved,
)
localNetworkAccessDestination(
onDismiss = { navController.popBackStack() },
onSplashScreenRemoved = onSplashScreenRemoved,
)
accessibilityDisclosureDestination(
onDismiss = { navController.popBackStack() },
onSplashScreenRemoved = onSplashScreenRemoved,
)
}
}
@Composable
private fun OverlayNavEventsEffect(
viewModel: OverlayNavViewModel,
navController: NavController,
) {
EventsEffect(viewModel = viewModel) { event ->
when (event) {
OverlayNavEvent.NavigateToCookieAcquisition -> {
navController.navigateToCookieAcquisition()
}
OverlayNavEvent.NavigateToLocalNetworkAccess -> {
navController.navigateToLocalNetworkAccess()
}
OverlayNavEvent.NavigateToAccessibilityDisclosure -> {
navController.navigateToAccessibilityDisclosure()
}
}
}
}

View File

@@ -0,0 +1,127 @@
package com.x8bit.bitwarden.ui.platform.feature.overlaynav
import androidx.lifecycle.viewModelScope
import com.bitwarden.ui.platform.base.BaseViewModel
import com.bitwarden.ui.platform.base.DeferredBackgroundEvent
import com.x8bit.bitwarden.data.platform.manager.CookieAcquisitionRequestManager
import com.x8bit.bitwarden.data.platform.manager.network.NetworkPermissionManager
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.filterNot
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import javax.inject.Inject
/**
* Manages the overlay navigation, hosting the root-navigation and any screen that can overlay it.
*/
@HiltViewModel
class OverlayNavViewModel @Inject constructor(
cookieAcquisitionRequestManager: CookieAcquisitionRequestManager,
networkPermissionManager: NetworkPermissionManager,
settingsRepository: SettingsRepository,
) : BaseViewModel<Unit, OverlayNavEvent, OverlayNavAction>(initialState = Unit) {
init {
settingsRepository
.hasShownAccessibilityDisclaimerFlow
.filterNot { it }
.map { OverlayNavAction.Internal.AccessibilityDisclosureRequired }
.onEach(::trySendAction)
.launchIn(viewModelScope)
networkPermissionManager
.isLocalNetworkAccessRequiredStateFlow
.filter { it }
.map { OverlayNavAction.Internal.LocalNetworkAccessRequired }
.onEach(::sendAction)
.launchIn(viewModelScope)
cookieAcquisitionRequestManager
.cookieAcquisitionRequestFlow
.filterNotNull()
.map { OverlayNavAction.Internal.CookieAcquisitionReady }
.onEach(::sendAction)
.launchIn(viewModelScope)
}
override fun handleAction(action: OverlayNavAction) {
when (action) {
is OverlayNavAction.Internal -> handleInternal(action)
}
}
private fun handleInternal(action: OverlayNavAction.Internal) {
when (action) {
OverlayNavAction.Internal.AccessibilityDisclosureRequired -> {
handleAccessibilityDisclosureRequired()
}
OverlayNavAction.Internal.CookieAcquisitionReady -> handleCookieAcquisitionReady()
OverlayNavAction.Internal.LocalNetworkAccessRequired -> {
handleLocalNetworkAccessRequired()
}
}
}
private fun handleAccessibilityDisclosureRequired() {
sendEvent(OverlayNavEvent.NavigateToAccessibilityDisclosure)
}
private fun handleCookieAcquisitionReady() {
sendEvent(OverlayNavEvent.NavigateToCookieAcquisition)
}
private fun handleLocalNetworkAccessRequired() {
sendEvent(OverlayNavEvent.NavigateToLocalNetworkAccess)
}
}
/**
* Models events for the overlay navigation screen.
*/
sealed class OverlayNavEvent {
/**
* Navigate to the cookie acquisition screen.
*/
data object NavigateToCookieAcquisition : OverlayNavEvent(), DeferredBackgroundEvent
/**
* Navigate to the local network access screen.
*/
data object NavigateToLocalNetworkAccess : OverlayNavEvent(), DeferredBackgroundEvent
/**
* Navigate to the accessibility disclosure screen.
*/
data object NavigateToAccessibilityDisclosure : OverlayNavEvent(), DeferredBackgroundEvent
}
/**
* Models actions for the overlay navigation screen.
*/
sealed class OverlayNavAction {
/**
* Internal ViewModel actions.
*/
sealed class Internal : OverlayNavAction() {
/**
* Indicates that the cookie acquisition conditions are met and navigation
* should proceed.
*/
data object CookieAcquisitionReady : Internal()
/**
* Indicates that the local network access is required.
*/
data object LocalNetworkAccessRequired : Internal()
/**
* Indicates that the accessibility disclosure needs to be displayed.
*/
data object AccessibilityDisclosureRequired : Internal()
}
}

View File

@@ -4,6 +4,7 @@ package com.x8bit.bitwarden.ui.platform.feature.premium.plan
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
@@ -72,6 +73,7 @@ import com.x8bit.bitwarden.ui.platform.composition.LocalAuthTabLaunchers
import com.x8bit.bitwarden.ui.platform.feature.premium.plan.handlers.PlanHandlers
import com.x8bit.bitwarden.ui.platform.feature.premium.plan.util.badgeColors
import com.x8bit.bitwarden.ui.platform.feature.premium.plan.util.labelRes
import com.x8bit.bitwarden.ui.platform.feature.premium.plan.util.showsFeatureList
import com.x8bit.bitwarden.ui.platform.model.AuthTabLaunchers
private const val PLACEHOLDER_TEXT: String = "--"
@@ -280,7 +282,6 @@ private fun FreeCloudContent(
handlers: PlanHandlers,
modifier: Modifier = Modifier,
) {
var shouldShowUpgradeDialog by rememberSaveable { mutableStateOf(false) }
Column(
modifier = modifier
.fillMaxSize()
@@ -295,53 +296,48 @@ private fun FreeCloudContent(
Spacer(modifier = Modifier.height(24.dp))
BitwardenFilledButton(
label = stringResource(id = BitwardenString.upgrade_now),
onClick = { shouldShowUpgradeDialog = true },
icon = rememberVectorPainter(id = BitwardenDrawable.ic_external_link),
modifier = Modifier
.standardHorizontalMargin()
.fillMaxWidth()
.testTag("UpgradeNowButton"),
)
Spacer(modifier = Modifier.height(12.dp))
Text(
text = stringResource(
id = BitwardenString
.youll_go_to_stripes_secure_checkout_to_complete_your_purchase,
),
style = BitwardenTheme.typography.bodyMedium,
color = BitwardenTheme.colorScheme.text.secondary,
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin()
.testTag("StripeFooterText"),
)
Spacer(modifier = Modifier.height(16.dp))
// Hide the Upgrade Now CTA (and its Stripe footer copy) while a Stripe upgrade is
// already in flight for the active user. CTAs reappear once the server flips the
// user to Premium.
if (!viewState.isPremiumUpgradePending) {
UpgradeNowCallToAction(
onUpgradeNowClick = handlers.onUpgradeNowClick,
)
}
Spacer(modifier = Modifier.navigationBarsPadding())
}
}
if (shouldShowUpgradeDialog) {
BitwardenTwoButtonDialog(
title = stringResource(id = BitwardenString.continue_to_stripe),
message = stringResource(
id = BitwardenString
.youll_go_to_stripes_secure_checkout_to_complete_your_purchase,
),
confirmButtonText = stringResource(id = BitwardenString.continue_text),
dismissButtonText = stringResource(id = BitwardenString.cancel),
onConfirmClick = {
shouldShowUpgradeDialog = false
handlers.onUpgradeNowClick()
},
onDismissClick = { shouldShowUpgradeDialog = false },
onDismissRequest = { shouldShowUpgradeDialog = false },
)
}
@Composable
private fun UpgradeNowCallToAction(
onUpgradeNowClick: () -> Unit,
) {
BitwardenFilledButton(
label = stringResource(id = BitwardenString.upgrade_now),
onClick = onUpgradeNowClick,
icon = rememberVectorPainter(id = BitwardenDrawable.ic_external_link),
modifier = Modifier
.standardHorizontalMargin()
.fillMaxWidth()
.testTag("UpgradeNowButton"),
)
Spacer(modifier = Modifier.height(12.dp))
Text(
text = stringResource(
id = BitwardenString.youll_go_to_stripes_secure_checkout_to_complete_your_purchase,
),
style = BitwardenTheme.typography.bodyMedium,
color = BitwardenTheme.colorScheme.text.secondary,
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin()
.testTag("StripeFooterText"),
)
Spacer(modifier = Modifier.height(16.dp))
}
@Suppress("MaxLineLength")
@@ -399,25 +395,30 @@ private fun PremiumFeaturesCard(
.standardHorizontalMargin(),
)
BitwardenHorizontalDivider()
PremiumFeatureRows()
}
}
val features = listOf(
BitwardenString.built_in_authenticator,
BitwardenString.emergency_access,
BitwardenString.secure_file_storage,
BitwardenString.breach_monitoring,
@Composable
private fun ColumnScope.PremiumFeatureRows() {
BitwardenHorizontalDivider()
val features = listOf(
BitwardenString.built_in_authenticator,
BitwardenString.emergency_access,
BitwardenString.secure_file_storage,
BitwardenString.breach_monitoring,
)
features.forEachIndexed { index, featureStringRes ->
BitwardenContentBlock(
data = ContentBlockData(
headerText = stringResource(id = featureStringRes),
iconVectorResource = BitwardenDrawable.ic_check_mark,
),
headerTextStyle = BitwardenTheme.typography.titleMedium,
showDivider = index != features.lastIndex,
modifier = Modifier.padding(vertical = 8.dp),
)
features.forEachIndexed { index, featureStringRes ->
BitwardenContentBlock(
data = ContentBlockData(
headerText = stringResource(id = featureStringRes),
iconVectorResource = BitwardenDrawable.ic_check_mark,
),
headerTextStyle = BitwardenTheme.typography.titleMedium,
showDivider = index != features.lastIndex,
modifier = Modifier.padding(vertical = 8.dp),
)
}
}
}
@@ -568,21 +569,18 @@ private fun PremiumContent(
}
}
@Suppress("LongMethod")
@Composable
private fun SubscriptionCard(
viewState: PlanState.ViewState.Premium,
modifier: Modifier = Modifier,
) {
val rowModifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin()
Column(
modifier = modifier
.fillMaxWidth()
.cardStyle(
cardStyle = CardStyle.Full,
// Override bottom padding; the final row owns its own spacing.
// Override bottom padding; the final row (line item or feature) owns its
// own spacing.
paddingBottom = 0.dp,
),
) {
@@ -599,55 +597,70 @@ private fun SubscriptionCard(
.standardHorizontalMargin(),
)
BitwardenHorizontalDivider()
SubscriptionLineItem(
label = stringResource(id = BitwardenString.billing_amount),
value = viewState.billingAmountText(),
testTag = "BillingAmountRow",
modifier = rowModifier,
)
viewState.storageCostText?.let { storageCostText ->
BitwardenHorizontalDivider(modifier = Modifier.padding(start = 16.dp))
SubscriptionLineItem(
label = stringResource(id = BitwardenString.storage_cost),
value = storageCostText,
testTag = "StorageCostRow",
modifier = rowModifier,
)
if (viewState.status?.showsFeatureList() == true) {
PremiumFeatureRows()
} else {
SubscriptionLineItems(viewState = viewState)
}
}
}
viewState.discountAmountText?.let { discountAmountText ->
BitwardenHorizontalDivider(modifier = Modifier.padding(start = 16.dp))
SubscriptionLineItem(
label = stringResource(id = BitwardenString.discount),
value = discountAmountText,
testTag = "DiscountRow",
modifier = rowModifier,
valueColor = BitwardenTheme.colorScheme.statusBadge.success.text,
)
}
@Composable
private fun SubscriptionLineItems(
viewState: PlanState.ViewState.Premium,
) {
val rowModifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin()
BitwardenHorizontalDivider()
SubscriptionLineItem(
label = stringResource(id = BitwardenString.billing_amount),
value = viewState.billingAmountText(),
testTag = "BillingAmountRow",
modifier = rowModifier,
)
viewState.storageCostText?.let { storageCostText ->
BitwardenHorizontalDivider(modifier = Modifier.padding(start = 16.dp))
SubscriptionLineItem(
label = stringResource(id = BitwardenString.estimated_tax),
value = viewState.estimatedTaxText,
testTag = "EstimatedTaxRow",
label = stringResource(id = BitwardenString.storage_cost),
value = storageCostText,
testTag = "StorageCostRow",
modifier = rowModifier,
)
BitwardenHorizontalDivider(modifier = Modifier.padding(start = 16.dp))
SubscriptionLineItem(
label = stringResource(id = BitwardenString.total),
value = viewState.totalText(),
testTag = "TotalRow",
modifier = rowModifier,
labelStyle = BitwardenTheme.typography.bodyLargeEmphasis,
)
}
viewState.discountAmountText?.let { discountAmountText ->
BitwardenHorizontalDivider(modifier = Modifier.padding(start = 16.dp))
SubscriptionLineItem(
label = stringResource(id = BitwardenString.discount),
value = discountAmountText,
testTag = "DiscountRow",
modifier = rowModifier,
valueColor = BitwardenTheme.colorScheme.statusBadge.success.text,
)
}
BitwardenHorizontalDivider(modifier = Modifier.padding(start = 16.dp))
SubscriptionLineItem(
label = stringResource(id = BitwardenString.estimated_tax),
value = viewState.estimatedTaxText,
testTag = "EstimatedTaxRow",
modifier = rowModifier,
)
BitwardenHorizontalDivider(modifier = Modifier.padding(start = 16.dp))
SubscriptionLineItem(
label = stringResource(id = BitwardenString.total),
value = viewState.totalText(),
testTag = "TotalRow",
modifier = rowModifier,
labelStyle = BitwardenTheme.typography.bodyLargeEmphasis,
)
}
@Composable
@@ -698,7 +711,7 @@ private fun SubscriptionHeader(
}
}
@Suppress("CyclomaticComplexMethod")
@Suppress("CyclomaticComplexMethod", "LongMethod")
@Composable
private fun subscriptionDescriptionText(
status: PremiumSubscriptionStatus?,
@@ -765,6 +778,13 @@ private fun subscriptionDescriptionText(
stringResource(id = BitwardenString.subscription_paused_description),
)
PremiumSubscriptionStatus.EXPIRED -> annotatedStringResource(
id = BitwardenString.subscription_expired_description,
args = arrayOf(suspensionDateText ?: PLACEHOLDER_TEXT),
style = baseStyle,
emphasisHighlightStyle = emphasisStyle,
)
null -> null
}
}
@@ -811,6 +831,7 @@ private fun PlanScreenFreeCloudAccount_preview() {
rate = "$1.67",
checkoutUrl = null,
isAwaitingPremiumStatus = false,
isPremiumUpgradePending = false,
),
handlers = PlanHandlers(
onBackClick = {},

View File

@@ -19,18 +19,22 @@ import com.x8bit.bitwarden.data.billing.manager.PremiumStateManager
import com.x8bit.bitwarden.data.billing.repository.BillingRepository
import com.x8bit.bitwarden.data.billing.repository.model.CheckoutSessionResult
import com.x8bit.bitwarden.data.billing.repository.model.CustomerPortalResult
import com.x8bit.bitwarden.data.billing.repository.model.PlanCadence
import com.x8bit.bitwarden.data.billing.repository.model.PremiumPlanPricingResult
import com.x8bit.bitwarden.data.billing.repository.model.PremiumSubscriptionStatus
import com.x8bit.bitwarden.data.billing.repository.model.SubscriptionInfo
import com.x8bit.bitwarden.data.billing.repository.model.SubscriptionResult
import com.x8bit.bitwarden.data.billing.repository.model.SubscriptionStatusState
import com.x8bit.bitwarden.data.billing.repository.model.UpgradeLifecycleState
import com.x8bit.bitwarden.data.billing.util.PremiumCheckoutCallbackResult
import com.x8bit.bitwarden.data.platform.manager.SpecialCircumstanceManager
import com.x8bit.bitwarden.data.platform.manager.model.SpecialCircumstance
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
import com.x8bit.bitwarden.data.vault.manager.model.SyncVaultDataResult
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.ui.platform.feature.premium.plan.util.toBillingAmountText
import com.x8bit.bitwarden.ui.platform.feature.premium.plan.util.toDiscountMoneyText
import com.x8bit.bitwarden.ui.platform.feature.premium.plan.util.toPresentMoneyText
import com.x8bit.bitwarden.ui.platform.feature.premium.plan.util.toRequiredMoneyText
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
@@ -39,7 +43,6 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize
import kotlinx.serialization.Serializable
import java.math.BigDecimal
import java.text.NumberFormat
import java.time.Clock
import java.time.Instant
@@ -91,6 +94,9 @@ class PlanViewModel @Inject constructor(
rate = PLACEHOLDER_TEXT,
checkoutUrl = null,
isAwaitingPremiumStatus = false,
isPremiumUpgradePending = premiumStateManager
.upgradeLifecycleStateFlow
.value is UpgradeLifecycleState.UpgradePending,
)
},
dialogState = null,
@@ -124,6 +130,12 @@ class PlanViewModel @Inject constructor(
.onEach(::sendAction)
.launchIn(viewModelScope)
premiumStateManager
.upgradeLifecycleStateFlow
.map { PlanAction.Internal.UpgradeLifecycleStateReceive(it) }
.onEach(::sendAction)
.launchIn(viewModelScope)
onFreeCloudContent {
viewModelScope.launch {
sendAction(
@@ -187,6 +199,10 @@ class PlanViewModel @Inject constructor(
is PlanAction.Internal.SubscriptionStatusUpdateReceive -> {
handleSubscriptionStatusUpdateReceive(action)
}
is PlanAction.Internal.UpgradeLifecycleStateReceive -> {
handleUpgradeLifecycleStateReceive(action)
}
}
}
@@ -403,6 +419,9 @@ class PlanViewModel @Inject constructor(
rate = PLACEHOLDER_TEXT,
checkoutUrl = null,
isAwaitingPremiumStatus = false,
isPremiumUpgradePending = premiumStateManager
.upgradeLifecycleStateFlow
.value is UpgradeLifecycleState.UpgradePending,
),
dialogState = PlanState.DialogState.Loading(
message = BitwardenString.loading.asText(),
@@ -433,6 +452,22 @@ class PlanViewModel @Inject constructor(
}
}
private fun handleUpgradeLifecycleStateReceive(
action: PlanAction.Internal.UpgradeLifecycleStateReceive,
) {
val isPending = action.state is UpgradeLifecycleState.UpgradePending
onFreeCloudContent { freeState ->
if (freeState.isPremiumUpgradePending == isPending) return@onFreeCloudContent
mutableStateFlow.update {
it.copy(
viewState = freeState.copy(
isPremiumUpgradePending = isPending,
),
)
}
}
}
private fun handleSubscriptionStatusUpdateReceive(
action: PlanAction.Internal.SubscriptionStatusUpdateReceive,
) {
@@ -561,14 +596,19 @@ class PlanViewModel @Inject constructor(
onFreeCloudContent { freeState ->
if (!freeState.isAwaitingPremiumStatus) return@onFreeCloudContent
val isPremium = authRepository
val activeAccount = authRepository
.userStateFlow
.value
?.activeAccount
?.isPremium == true
val isPremium = activeAccount?.isPremium == true
if (isPremium) {
onPremiumUpgradeSuccess()
} else {
// Persist the pending-upgrade signal so the Vault banner and the Plan-screen
// Upgrade Now CTA can suppress themselves while the server catches up.
activeAccount?.userId?.let { userId ->
premiumStateManager.markPremiumUpgradePending(userId = userId)
}
mutableStateFlow.update {
it.copy(
dialogState = PlanState.DialogState.PendingUpgrade,
@@ -675,11 +715,11 @@ class PlanViewModel @Inject constructor(
return PlanState.ViewState.Premium(
status = status,
billingAmountText = seatsCost.toBillingAmountText(cadence),
storageCostText = storageCost.toOptionalMoneyText(),
discountAmountText = discountAmount.toOptionalMoneyText(negative = true),
estimatedTaxText = estimatedTax.toRequiredMoneyText(),
totalText = nextChargeTotal.toBillingAmountText(cadence),
billingAmountText = seatsCost.toBillingAmountText(cadence, currencyFormatter),
storageCostText = storageCost.toPresentMoneyText(currencyFormatter),
discountAmountText = discountAmount.toDiscountMoneyText(currencyFormatter),
estimatedTaxText = estimatedTax.toRequiredMoneyText(currencyFormatter),
totalText = nextChargeTotal.toBillingAmountText(cadence, currencyFormatter),
nextChargeTotalText = formattedTotal,
nextChargeDateText = formattedDate,
cancelAtDateText = formattedCancelAt,
@@ -690,36 +730,6 @@ class PlanViewModel @Inject constructor(
)
}
private fun BigDecimal.toBillingAmountText(cadence: PlanCadence): Text {
val formatted = currencyFormatter.format(this)
val cadenceRes = when (cadence) {
PlanCadence.ANNUALLY -> BitwardenString.billing_rate_per_year
PlanCadence.MONTHLY -> BitwardenString.billing_rate_per_month
}
return cadenceRes.asText(formatted)
}
/**
* Formats this amount for an always-rendered line item. Null is coerced to zero so the row
* still shows the locale-formatted `$0.00`, matching the Web convention of always rendering
* the Estimated Tax and Total rows.
*/
private fun BigDecimal?.toRequiredMoneyText(): String =
currencyFormatter.format(this ?: BigDecimal.ZERO)
/**
* Formats this amount for a hide-when-absent line item. Returns `null` when the amount is
* `null` or non-positive so the caller can omit the row entirely (Discount, Storage).
* When [negative] is true, the formatted value is prefixed with `-` to match the canonical
* Web discount styling.
*/
private fun BigDecimal?.toOptionalMoneyText(negative: Boolean = false): String? =
when {
this == null || this.signum() <= 0 -> null
negative -> "-${currencyFormatter.format(this)}"
else -> currencyFormatter.format(this)
}
private fun Instant.toLocalizedDate(): String =
toFormattedDateStyle(
dateStyle = FormatStyle.LONG,
@@ -802,6 +812,7 @@ data class PlanState(
val rate: String,
val checkoutUrl: String?,
val isAwaitingPremiumStatus: Boolean,
val isPremiumUpgradePending: Boolean,
) : Free()
/**
@@ -1119,23 +1130,32 @@ sealed class PlanAction {
data class SubscriptionStatusUpdateReceive(
val state: SubscriptionStatusState,
) : Internal()
/**
* The shared [UpgradeLifecycleState] for the active user has updated.
*/
data class UpgradeLifecycleStateReceive(
val state: UpgradeLifecycleState,
) : Internal()
}
}
/**
* Returns `true` when this status corresponds to a subscription that the user can still
* cancel through the Stripe portal — i.e., a live or recoverable subscription. Terminal
* states (canceled) do not present a cancel action.
* cancel through the Stripe portal — i.e., a live subscription. Terminal states (canceled,
* expired, pending cancellation) and states whose primary action is recovering payment
* (update payment) do not present a cancel action.
*/
private fun PremiumSubscriptionStatus.canBeCanceled(): Boolean = when (this) {
PremiumSubscriptionStatus.CANCELED,
PremiumSubscriptionStatus.EXPIRED,
PremiumSubscriptionStatus.PENDING_CANCELLATION,
PremiumSubscriptionStatus.UPDATE_PAYMENT,
-> false
PremiumSubscriptionStatus.ACTIVE,
PremiumSubscriptionStatus.PAST_DUE,
PremiumSubscriptionStatus.PAUSED,
PremiumSubscriptionStatus.UPDATE_PAYMENT,
-> true
}
@@ -1147,6 +1167,7 @@ private fun PremiumSubscriptionStatus.canBeCanceled(): Boolean = when (this) {
*/
private fun PremiumSubscriptionStatus.isPremiumViewEligible(): Boolean = when (this) {
PremiumSubscriptionStatus.CANCELED,
PremiumSubscriptionStatus.EXPIRED,
PremiumSubscriptionStatus.PAST_DUE,
PremiumSubscriptionStatus.PAUSED,
PremiumSubscriptionStatus.PENDING_CANCELLATION,

View File

@@ -0,0 +1,48 @@
package com.x8bit.bitwarden.ui.platform.feature.premium.plan.util
import com.bitwarden.ui.platform.resource.BitwardenString
import com.bitwarden.ui.util.Text
import com.bitwarden.ui.util.asText
import com.x8bit.bitwarden.data.billing.repository.model.PlanCadence
import java.math.BigDecimal
import java.text.NumberFormat
/**
* Formats this amount as a cadence-qualified billing rate (e.g. "$10.00 per year"), using
* [currencyFormatter] for the locale-aware currency value.
*/
fun BigDecimal.toBillingAmountText(
cadence: PlanCadence,
currencyFormatter: NumberFormat,
): Text {
val formatted = currencyFormatter.format(this)
val cadenceRes = when (cadence) {
PlanCadence.ANNUALLY -> BitwardenString.billing_rate_per_year
PlanCadence.MONTHLY -> BitwardenString.billing_rate_per_month
}
return cadenceRes.asText(formatted)
}
/**
* Formats this amount for an always-rendered line item. Null is coerced to zero so the row still
* shows the locale-formatted `$0.00`, as the Estimated Tax and Total rows always render.
*/
fun BigDecimal?.toRequiredMoneyText(currencyFormatter: NumberFormat): String =
currencyFormatter.format(this ?: BigDecimal.ZERO)
/**
* Formats this amount for a render-when-present line item (Storage), rendering `$0.00` for a
* free line and returning `null` only when the amount is `null`.
*/
fun BigDecimal?.toPresentMoneyText(currencyFormatter: NumberFormat): String? =
this?.let { currencyFormatter.format(it) }
/**
* Formats this amount as a negative money string for the Discount line item (e.g. "-$5.00"),
* returning `null` when the amount is `null` or non-positive so the row is omitted when there is
* no discount.
*/
fun BigDecimal?.toDiscountMoneyText(currencyFormatter: NumberFormat): String? =
this
?.takeIf { it.signum() > 0 }
?.let { "\u2212${currencyFormatter.format(it)}" }

View File

@@ -14,6 +14,7 @@ import com.x8bit.bitwarden.data.billing.repository.model.PremiumSubscriptionStat
fun PremiumSubscriptionStatus.labelRes(): Int = when (this) {
PremiumSubscriptionStatus.ACTIVE -> BitwardenString.subscription_status_active
PremiumSubscriptionStatus.CANCELED -> BitwardenString.subscription_status_canceled
PremiumSubscriptionStatus.EXPIRED -> BitwardenString.subscription_status_expired
PremiumSubscriptionStatus.PENDING_CANCELLATION -> {
BitwardenString.subscription_status_pending_cancellation
}
@@ -23,6 +24,24 @@ fun PremiumSubscriptionStatus.labelRes(): Int = when (this) {
PremiumSubscriptionStatus.UPDATE_PAYMENT -> BitwardenString.subscription_status_update_payment
}
/**
* Returns `true` when the Premium plan card should replace its billing line items with the
* premium feature list. Reserved for terminal states where line items carry no actionable
* information and the user's path forward is to resubscribe.
*/
fun PremiumSubscriptionStatus.showsFeatureList(): Boolean = when (this) {
PremiumSubscriptionStatus.CANCELED,
PremiumSubscriptionStatus.EXPIRED,
-> true
PremiumSubscriptionStatus.ACTIVE,
PremiumSubscriptionStatus.PAST_DUE,
PremiumSubscriptionStatus.PAUSED,
PremiumSubscriptionStatus.PENDING_CANCELLATION,
PremiumSubscriptionStatus.UPDATE_PAYMENT,
-> false
}
/**
* Returns the [BitwardenColorScheme.StatusBadgeVariantColors] used to render the badge for a
* [PremiumSubscriptionStatus].
@@ -31,7 +50,10 @@ fun PremiumSubscriptionStatus.labelRes(): Int = when (this) {
fun PremiumSubscriptionStatus.badgeColors(): BitwardenColorScheme.StatusBadgeVariantColors =
when (this) {
PremiumSubscriptionStatus.ACTIVE -> BitwardenTheme.colorScheme.statusBadge.success
PremiumSubscriptionStatus.CANCELED -> BitwardenTheme.colorScheme.statusBadge.error
PremiumSubscriptionStatus.CANCELED,
PremiumSubscriptionStatus.EXPIRED,
-> BitwardenTheme.colorScheme.statusBadge.error
PremiumSubscriptionStatus.PAST_DUE,
PremiumSubscriptionStatus.PAUSED,
PremiumSubscriptionStatus.PENDING_CANCELLATION,

View File

@@ -268,7 +268,7 @@ class RootNavViewModel @Inject constructor(
?.let(::parseJwtTokenDataOrNull)
?.isExternal == true
val usesKeyConnectorAndNotAdmin = this.activeAccount.organizations.any {
it.shouldUseKeyConnector &&
it.isKeyConnectorEnabled &&
it.role != OrganizationType.OWNER &&
it.role != OrganizationType.ADMIN
}

View File

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

View File

@@ -8,7 +8,7 @@ import com.bitwarden.core.data.repository.model.DataState
import com.bitwarden.data.repository.util.baseIconUrl
import com.bitwarden.data.repository.util.baseWebSendUrl
import com.bitwarden.data.repository.util.baseWebVaultUrlOrDefault
import com.bitwarden.network.model.PolicyTypeJson
import com.bitwarden.policies.PolicyType
import com.bitwarden.send.SendType
import com.bitwarden.ui.platform.base.BackgroundEvent
import com.bitwarden.ui.platform.base.BaseViewModel
@@ -127,7 +127,7 @@ class SearchViewModel @Inject constructor(
is SearchType.Sends -> null
is SearchType.Vault -> userState.activeAccount.toVaultFilterData(
isIndividualVaultDisabled = policyManager
.getActivePolicies(type = PolicyTypeJson.PERSONAL_OWNERSHIP)
.getActivePolicies(type = PolicyType.ORGANIZATION_DATA_OWNERSHIP)
.any(),
)
},
@@ -156,7 +156,7 @@ class SearchViewModel @Inject constructor(
.launchIn(viewModelScope)
policyManager
.getActivePoliciesFlow(type = PolicyTypeJson.RESTRICT_ITEM_TYPES)
.getActivePoliciesFlow(type = PolicyType.RESTRICTED_ITEM_TYPES)
.map { policies -> policies.map { it.organizationId } }
.map { SearchAction.Internal.RestrictItemTypesPolicyUpdateReceive(it) }
.onEach(::sendAction)

View File

@@ -12,9 +12,7 @@ import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
@@ -28,6 +26,8 @@ import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
@@ -37,7 +37,6 @@ import com.bitwarden.ui.platform.base.util.standardHorizontalMargin
import com.bitwarden.ui.platform.components.account.dialog.BitwardenLogoutConfirmationDialog
import com.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar
import com.bitwarden.ui.platform.components.badge.NotificationBadge
import com.bitwarden.ui.platform.components.button.BitwardenTextButton
import com.bitwarden.ui.platform.components.card.BitwardenActionCard
import com.bitwarden.ui.platform.components.card.actionCardExitAnimation
import com.bitwarden.ui.platform.components.dialog.BitwardenBasicDialog
@@ -635,50 +634,23 @@ private fun FingerPrintPhraseDialog(
onDismissRequest: () -> Unit,
onLearnMore: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismissRequest,
dismissButton = {
BitwardenTextButton(
label = stringResource(id = BitwardenString.close),
onClick = onDismissRequest,
)
},
confirmButton = {
BitwardenTextButton(
label = stringResource(id = BitwardenString.learn_more),
isExternalLink = true,
onClick = onLearnMore,
)
},
title = {
Text(
text = stringResource(id = BitwardenString.fingerprint_phrase),
color = BitwardenTheme.colorScheme.text.primary,
style = BitwardenTheme.typography.headlineSmall,
modifier = Modifier.fillMaxWidth(),
)
},
text = {
Column {
Text(
text = "${stringResource(id = BitwardenString.your_accounts_fingerprint)}:",
color = BitwardenTheme.colorScheme.text.primary,
style = BitwardenTheme.typography.bodyMedium,
modifier = Modifier.fillMaxWidth(),
)
Spacer(modifier = Modifier.height(24.dp))
Text(
text = fingerprintPhrase(),
color = BitwardenTheme.colorScheme.text.codePink,
style = BitwardenTheme.typography.sensitiveInfoSmall,
modifier = Modifier.fillMaxWidth(),
)
BitwardenTwoButtonDialog(
title = stringResource(id = BitwardenString.fingerprint_phrase),
message = buildAnnotatedString {
append("${stringResource(id = BitwardenString.your_accounts_fingerprint)}:\n\n")
withStyle(
style = BitwardenTheme.typography.sensitiveInfoSmall
.toSpanStyle()
.copy(color = BitwardenTheme.colorScheme.text.codePink),
) {
append(fingerprintPhrase())
}
},
containerColor = BitwardenTheme.colorScheme.background.primary,
iconContentColor = BitwardenTheme.colorScheme.icon.secondary,
titleContentColor = BitwardenTheme.colorScheme.text.primary,
textContentColor = BitwardenTheme.colorScheme.text.primary,
confirmButtonText = stringResource(id = BitwardenString.learn_more),
dismissButtonText = stringResource(id = BitwardenString.close),
onConfirmClick = onLearnMore,
onDismissClick = onDismissRequest,
onDismissRequest = onDismissRequest,
)
}

View File

@@ -7,7 +7,7 @@ import androidx.lifecycle.viewModelScope
import com.bitwarden.core.data.manager.model.FlagKey
import com.bitwarden.core.util.isBuildVersionAtLeast
import com.bitwarden.data.repository.util.baseWebVaultUrlOrDefault
import com.bitwarden.network.model.PolicyTypeJson
import com.bitwarden.policies.PolicyType
import com.bitwarden.ui.platform.base.BaseViewModel
import com.bitwarden.ui.platform.resource.BitwardenPlurals
import com.bitwarden.ui.platform.resource.BitwardenString
@@ -94,7 +94,7 @@ class AccountSecurityViewModel @Inject constructor(
.launchIn(viewModelScope)
policyManager
.getActivePoliciesFlow(type = PolicyTypeJson.MAXIMUM_VAULT_TIMEOUT)
.getActivePoliciesFlow(type = PolicyType.MAXIMUM_VAULT_TIMEOUT)
.map { policies ->
AccountSecurityAction.Internal.PolicyUpdateReceive(
vaultTimeoutPolicies = policies.mapNotNull {
@@ -106,7 +106,7 @@ class AccountSecurityViewModel @Inject constructor(
.launchIn(viewModelScope)
policyManager
.getActivePoliciesFlow(type = PolicyTypeJson.REMOVE_UNLOCK_WITH_PIN)
.getActivePoliciesFlow(type = PolicyType.REMOVE_UNLOCK_WITH_PIN)
.map { policies ->
AccountSecurityAction.Internal.RemovePinPolicyUpdateReceive(
removeUnlockWithPinPolicyEnabled = policies.isNotEmpty(),

View File

@@ -9,7 +9,6 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.requiredHeightIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Text
@@ -78,7 +77,7 @@ fun PinInputDialog(
// This background is necessary for the dialog to not be transparent.
.background(
color = BitwardenTheme.colorScheme.background.primary,
shape = RoundedCornerShape(28.dp),
shape = BitwardenTheme.shapes.dialog,
),
horizontalAlignment = Alignment.End,
) {

View File

@@ -9,7 +9,6 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.requiredHeightIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -57,7 +56,7 @@ fun AddEditBlockedUriDialog(
// This background is necessary for the dialog to not be transparent.
.background(
color = BitwardenTheme.colorScheme.background.primary,
shape = RoundedCornerShape(28.dp),
shape = BitwardenTheme.shapes.dialog,
),
horizontalAlignment = Alignment.End,
) {

View File

@@ -255,7 +255,7 @@ private fun PrivilegedAppsListContent(
) {
BitwardenStandardIconButton(
vectorIconRes = BitwardenDrawable.ic_delete,
contentDescription = "",
contentDescription = stringResource(id = BitwardenString.delete),
onClick = remember(item) {
{ onDeleteClick(item) }
},

View File

@@ -6,7 +6,7 @@ import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import com.bitwarden.core.data.util.toFormattedPattern
import com.bitwarden.data.manager.file.FileManager
import com.bitwarden.network.model.PolicyTypeJson
import com.bitwarden.policies.PolicyType
import com.bitwarden.ui.platform.base.BaseViewModel
import com.bitwarden.ui.platform.components.snackbar.model.BitwardenSnackbarData
import com.bitwarden.ui.platform.resource.BitwardenString
@@ -66,7 +66,7 @@ class ExportVaultViewModel @Inject constructor(
passwordInput = "",
passwordStrengthState = PasswordStrengthState.NONE,
policyPreventsExport = policyManager
.getActivePolicies(type = PolicyTypeJson.DISABLE_PERSONAL_VAULT_EXPORT)
.getActivePolicies(type = PolicyType.DISABLE_PERSONAL_VAULT_EXPORT)
.any(),
showSendCodeButton = authRepository
.userStateFlow

View File

@@ -140,6 +140,7 @@ private fun FlightRecorderContent(
selectedOption = state.selectedDuration,
onOptionSelected = onDurationSelected,
modifier = Modifier
.testTag("LoggingDurationChooser")
.fillMaxWidth()
.standardHorizontalMargin(),
)

View File

@@ -2,7 +2,7 @@ package com.x8bit.bitwarden.ui.platform.feature.settings.vault
import androidx.lifecycle.viewModelScope
import com.bitwarden.core.data.manager.BuildInfoManager
import com.bitwarden.network.model.PolicyTypeJson
import com.bitwarden.policies.PolicyType
import com.bitwarden.ui.platform.base.BackgroundEvent
import com.bitwarden.ui.platform.base.BaseViewModel
import com.bitwarden.ui.platform.components.snackbar.model.BitwardenSnackbarData
@@ -54,7 +54,7 @@ class VaultSettingsViewModel @Inject constructor(
.launchIn(viewModelScope)
policyManager
.getActivePoliciesFlow(type = PolicyTypeJson.PERSONAL_OWNERSHIP)
.getActivePoliciesFlow(type = PolicyType.ORGANIZATION_DATA_OWNERSHIP)
.map { policies ->
VaultSettingsAction.Internal.CredentialExchangeAvailabilityChanged(
isEnabled = !buildInfoManager.isFdroid && policies.isEmpty(),
@@ -133,7 +133,7 @@ class VaultSettingsViewModel @Inject constructor(
private fun handleImportItemsClicked() {
if (!buildInfoManager.isFdroid &&
policyManager.getActivePolicies(PolicyTypeJson.PERSONAL_OWNERSHIP).isEmpty()
policyManager.getActivePolicies(PolicyType.ORGANIZATION_DATA_OWNERSHIP).isEmpty()
) {
sendEvent(VaultSettingsEvent.NavigateToImportItems)
} else {

Some files were not shown because too many files have changed in this diff Show More